refactor: improve Snackbar functionality
- 增强 Codec 枚举,增加 type 和 lossless 属性以改进编解码器管理 - 引入 SnackbarController - 改进 Settings.kt 中的设置处理,优化状态管理 - 增强 DeviceWidgets,支持编辑设备详情并改进 UI - 重构 ReorderableList 和 VirtualButtons,优化布局和间距 - 重构 Scrpcy 编码器获取逻辑
This commit is contained in:
@@ -8,6 +8,7 @@ import android.view.Surface
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.ArrayDeque
|
||||
@@ -133,8 +134,8 @@ class NativeCoreFacade private constructor() {
|
||||
videoFpsListeners.remove(listener)
|
||||
}
|
||||
|
||||
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
|
||||
session?.pressBackOrScreenOn(action)
|
||||
suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
|
||||
session?.pressBackOrTurnScreenOn(action)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +201,7 @@ class NativeCoreFacade private constructor() {
|
||||
@Volatile
|
||||
private var instance: NativeCoreFacade? = null
|
||||
|
||||
// TODO ???
|
||||
fun get(context: Context): NativeCoreFacade {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: NativeCoreFacade().also { instance = it }
|
||||
@@ -252,16 +254,19 @@ class NativeCoreFacade private constructor() {
|
||||
decoder = null
|
||||
Log.i(
|
||||
TAG,
|
||||
"createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true"
|
||||
"createOrReplaceDecoder(): " +
|
||||
"codec=${session.codec?.string ?: "null"}, " +
|
||||
"size=${session.width}x${session.height}, " +
|
||||
"persistent=true"
|
||||
)
|
||||
val newDecoder = AnnexBDecoder(
|
||||
width = session.width,
|
||||
height = session.height,
|
||||
outputSurface = surface,
|
||||
mimeType = when (session.codecName.lowercase()) {
|
||||
"h264" -> "video/avc"
|
||||
"h265" -> "video/hevc"
|
||||
"av1" -> "video/av01"
|
||||
mimeType = when (session.codec) {
|
||||
Codec.H264 -> "video/avc"
|
||||
Codec.H265 -> "video/hevc"
|
||||
Codec.AV1 -> "video/av01"
|
||||
else -> "video/avc"
|
||||
},
|
||||
onOutputSizeChanged = { width, height ->
|
||||
@@ -334,38 +339,6 @@ class NativeCoreFacade private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a single consumer to the session manager to deliver incoming video packets
|
||||
* to all active decoders.
|
||||
*
|
||||
* - Called when a session is active and at least one decoder exists. Packets are
|
||||
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
|
||||
* - Kept only for documentation parity with the old multi-decoder design.
|
||||
*/
|
||||
@Deprecated("TODO: Determine if this is really unnecessary")
|
||||
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
|
||||
sessionMgr.attachVideoConsumer { packet ->
|
||||
cacheBootstrapPacket(packet)
|
||||
packetCount += 1
|
||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
|
||||
)
|
||||
}
|
||||
val currentDecoder = decoder ?: return@attachVideoConsumer
|
||||
if (activeSurfaceId == null) return@attachVideoConsumer
|
||||
runCatching {
|
||||
currentDecoder.feedAnnexB(
|
||||
packet.data,
|
||||
packet.ptsUs,
|
||||
packet.isKeyFrame,
|
||||
packet.isConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseAllDecoders() {
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
|
||||
@@ -58,6 +58,6 @@ fun Preset<Int>.indexOfOrNearest(raw: String): Int {
|
||||
object ScrcpyPresets {
|
||||
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px
|
||||
val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps
|
||||
val AudioBitRate = Preset(listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
|
||||
val AudioBitRate = Preset(listOf(0, 32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
|
||||
val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,10 @@ object UiSpacing {
|
||||
val SectionTitleTop = 12.dp
|
||||
val SectionTitleBottom = 6.dp
|
||||
val FieldLabelBottom = 4.dp
|
||||
val CardContent = 12.dp
|
||||
val Content = 12.dp
|
||||
val ContentVertical = 12.dp
|
||||
val ContentHorizontal = 12.dp
|
||||
val CardTitle = 16.dp
|
||||
val BottomContent = 64.dp
|
||||
val BottomSheetBottom = 16.dp
|
||||
val PageBottom = 64.dp
|
||||
val SheetBottom = 16.dp
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
||||
host: String? = null,
|
||||
port: Int? = null,
|
||||
name: String? = null,
|
||||
online: Boolean? = null,
|
||||
newPort: Int? = null,
|
||||
updateNameOnlyWhenEmpty: Boolean = false,
|
||||
): DeviceShortcuts {
|
||||
@@ -48,6 +47,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
||||
|
||||
if (idx < 0) return this
|
||||
val old = devices[idx]
|
||||
val updateById = id != null
|
||||
|
||||
// 确定最终的属性值
|
||||
val finalName = when {
|
||||
@@ -55,23 +55,24 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
||||
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||
else -> name
|
||||
}
|
||||
val finalPort = newPort ?: old.port
|
||||
val finalOnline = online ?: old.online
|
||||
val finalHost = if (updateById) host ?: old.host else old.host
|
||||
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
|
||||
|
||||
// 若无任何变化,返回原实例
|
||||
if (finalName == old.name && finalPort == old.port && finalOnline == old.online)
|
||||
if (finalName == old.name && finalHost == old.host && finalPort == old.port)
|
||||
return this
|
||||
|
||||
val newList = devices.toMutableList().apply {
|
||||
this[idx] = DeviceShortcut(
|
||||
name = finalName,
|
||||
host = old.host,
|
||||
host = finalHost,
|
||||
port = finalPort,
|
||||
online = finalOnline
|
||||
)
|
||||
}
|
||||
return DeviceShortcuts(
|
||||
if (newPort != null && newPort != old.port)
|
||||
if ((updateById && (finalHost != old.host || finalPort != old.port))
|
||||
|| (newPort != null && newPort != old.port)
|
||||
)
|
||||
newList.distinctBy { it.id }
|
||||
else newList
|
||||
)
|
||||
@@ -118,7 +119,6 @@ data class DeviceShortcut(
|
||||
val name: String = "",
|
||||
val host: String,
|
||||
val port: Int = Defaults.ADB_PORT,
|
||||
val online: Boolean = false,
|
||||
) {
|
||||
val id: String get() = "$host:$port"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.media.AudioTrack
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
@@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
||||
}"
|
||||
)
|
||||
when (codecId) {
|
||||
AUDIO_CODEC_OPUS -> prepareOpus(data)
|
||||
AUDIO_CODEC_AAC -> prepareAac(data)
|
||||
AUDIO_CODEC_FLAC -> prepareFlac(data)
|
||||
Codec.OPUS.id -> prepareOpus(data)
|
||||
Codec.AAC.id -> prepareAac(data)
|
||||
Codec.FLAC.id -> prepareFlac(data)
|
||||
// RAW has no config packet
|
||||
}
|
||||
return
|
||||
@@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
||||
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
|
||||
}
|
||||
|
||||
if (codecId == AUDIO_CODEC_RAW) {
|
||||
if (codecId == Codec.RAW.id) {
|
||||
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
|
||||
return
|
||||
}
|
||||
@@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScrcpyAudioPlayer"
|
||||
const val AUDIO_CODEC_OPUS = 0x6f707573
|
||||
const val AUDIO_CODEC_AAC = 0x00616163
|
||||
const val AUDIO_CODEC_FLAC = 0x666c6163
|
||||
const val AUDIO_CODEC_RAW = 0x00726177
|
||||
private const val SAMPLE_RATE = 48000
|
||||
private const val CHANNELS = 2
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -34,16 +33,17 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
||||
@@ -52,7 +52,9 @@ import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -67,7 +69,6 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
@@ -88,7 +89,7 @@ fun DeviceTabScreen(
|
||||
nativeCore: NativeCoreFacade,
|
||||
adbService: NativeAdbService,
|
||||
scrcpy: Scrcpy,
|
||||
snack: SnackbarHostState,
|
||||
snackbar: SnackbarController,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
onOpenVirtualButtonOrder: () -> Unit,
|
||||
onSessionStartedChange: (Boolean) -> Unit,
|
||||
@@ -139,7 +140,7 @@ fun DeviceTabScreen(
|
||||
nativeCore = nativeCore,
|
||||
adbService = adbService,
|
||||
scrcpy = scrcpy,
|
||||
snack = snack,
|
||||
snackbar = snackbar,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onSessionStartedChange = onSessionStartedChange,
|
||||
onOpenAdvancedPage = onOpenAdvancedPage,
|
||||
@@ -154,13 +155,14 @@ fun DeviceTabPage(
|
||||
nativeCore: NativeCoreFacade,
|
||||
adbService: NativeAdbService,
|
||||
scrcpy: Scrcpy,
|
||||
snack: SnackbarHostState,
|
||||
snackbar: SnackbarController,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
onSessionStartedChange: (Boolean) -> Unit,
|
||||
onOpenAdvancedPage: () -> Unit,
|
||||
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
|
||||
val haptics = rememberAppHaptics()
|
||||
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
@@ -180,7 +182,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
appSettings.saveBundle(asBundleLatest)
|
||||
}
|
||||
}
|
||||
@@ -203,7 +205,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
quickDevices.saveBundle(qdBundleLatest)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +227,7 @@ fun DeviceTabPage(
|
||||
var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) }
|
||||
var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) }
|
||||
var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") }
|
||||
var sessionInfoCodec by rememberSaveable { mutableStateOf("") }
|
||||
var sessionInfoCodec by rememberSaveable { mutableStateOf<Codec?>(null) }
|
||||
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
|
||||
var sessionInfo by remember {
|
||||
mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
|
||||
@@ -243,7 +245,7 @@ fun DeviceTabPage(
|
||||
height = sessionInfoHeight,
|
||||
deviceName = sessionInfoDeviceName,
|
||||
codecId = 0,
|
||||
codecName = sessionInfoCodec,
|
||||
codec = sessionInfoCodec,
|
||||
audioCodecId = 0,
|
||||
controlEnabled = sessionInfoControlEnabled,
|
||||
host = currentTargetHost,
|
||||
@@ -254,7 +256,7 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
|
||||
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -288,6 +290,9 @@ fun DeviceTabPage(
|
||||
qdBundle = qdBundle.copy(quickDevicesList = serialized)
|
||||
}
|
||||
}
|
||||
val editingDevice = remember(savedShortcuts, editingDeviceId) {
|
||||
editingDeviceId?.let(savedShortcuts::get)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the current ADB connection and stop any running scrcpy session.
|
||||
@@ -330,14 +335,12 @@ fun DeviceTabPage(
|
||||
clearQuickOnlineForTarget?.let { target ->
|
||||
if (target.host.isNotBlank())
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host, port = target.port, online = false
|
||||
host = target.host, port = target.port
|
||||
)
|
||||
}
|
||||
logMessage?.let { logEvent(it) }
|
||||
if (!showSnackMessage.isNullOrBlank()) {
|
||||
scope.launch {
|
||||
snack.showSnackbar(showSnackMessage)
|
||||
}
|
||||
snackbar.show(showSnackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +450,7 @@ fun DeviceTabPage(
|
||||
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
|
||||
// For non-adb actions (start/stop/pair/list refresh...).
|
||||
if (busy) return
|
||||
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
taskScope.launch {
|
||||
busy = true
|
||||
try {
|
||||
block()
|
||||
@@ -456,9 +459,7 @@ fun DeviceTabPage(
|
||||
} catch (e: IllegalArgumentException) {
|
||||
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||
logEvent("$label 参数错误: $detail", Log.WARN, e)
|
||||
scope.launch {
|
||||
snack.showSnackbar("$label 参数错误: $detail")
|
||||
}
|
||||
snackbar.show("$label 参数错误: $detail")
|
||||
} catch (e: Exception) {
|
||||
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||
logEvent("$label 失败: $detail", Log.ERROR, e)
|
||||
@@ -489,7 +490,7 @@ fun DeviceTabPage(
|
||||
) {
|
||||
// For manual adb operations from user actions.
|
||||
if (adbConnecting) return
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
onStarted?.invoke()
|
||||
adbConnecting = true
|
||||
try {
|
||||
@@ -499,9 +500,7 @@ fun DeviceTabPage(
|
||||
} catch (e: IllegalArgumentException) {
|
||||
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||
logEvent("$label 参数错误: $detail", Log.WARN, e)
|
||||
scope.launch {
|
||||
snack.showSnackbar("$label 参数错误: $detail")
|
||||
}
|
||||
snackbar.show("$label 参数错误: $detail")
|
||||
} catch (e: Exception) {
|
||||
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
|
||||
logEvent("$label 失败: $detail", Log.ERROR, e)
|
||||
@@ -521,57 +520,6 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshEncoderLists() {
|
||||
if (!adbConnected) return
|
||||
runCatching {
|
||||
scrcpy.refreshEncoders()
|
||||
}.onSuccess {
|
||||
// Validate current selections
|
||||
if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) {
|
||||
scrcpyOptions.updateBundle { it.copy(videoEncoder = "") }
|
||||
}
|
||||
if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) {
|
||||
scrcpyOptions.updateBundle { it.copy(audioEncoder = "") }
|
||||
}
|
||||
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
|
||||
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
|
||||
logEvent(
|
||||
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
|
||||
Log.WARN
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logEvent(
|
||||
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
|
||||
Log.ERROR,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshCameraSizeLists() {
|
||||
if (!adbConnected) return
|
||||
runCatching {
|
||||
scrcpy.refreshCameraSizes()
|
||||
}.onSuccess {
|
||||
// Validate current selection
|
||||
if (
|
||||
soBundleShared.cameraSize.isNotBlank()
|
||||
&& soBundleShared.cameraSize != "custom"
|
||||
&& soBundleShared.cameraSize !in scrcpy.cameraSizes
|
||||
) {
|
||||
scrcpyOptions.updateBundle { it.copy(cameraSize = "") }
|
||||
}
|
||||
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
|
||||
}.onFailure { e ->
|
||||
logEvent(
|
||||
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
|
||||
Log.ERROR,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleAdbConnected(host: String, port: Int) {
|
||||
currentTargetHost = host
|
||||
currentTargetPort = port
|
||||
@@ -586,8 +534,7 @@ fun DeviceTabPage(
|
||||
connectedDeviceLabel = info.model
|
||||
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = host,
|
||||
port = port,
|
||||
host = host, port = port,
|
||||
name = fullLabel,
|
||||
updateNameOnlyWhenEmpty = true
|
||||
)
|
||||
@@ -603,26 +550,7 @@ fun DeviceTabPage(
|
||||
"android=${info.androidRelease.ifBlank { "unknown" }}, " +
|
||||
"sdk=${info.sdkInt}"
|
||||
)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 已连接")
|
||||
}
|
||||
refreshEncoderLists()
|
||||
refreshCameraSizeLists()
|
||||
}
|
||||
|
||||
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, savedShortcuts.size) {
|
||||
val activeId = if (adbConnected && currentTargetHost.isNotBlank()) {
|
||||
"$currentTargetHost:$currentTargetPort"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
for (index in savedShortcuts.indices) {
|
||||
val item = savedShortcuts[index]
|
||||
val shouldOnline = activeId != null && item.id == activeId
|
||||
if (item.online != shouldOnline) {
|
||||
savedShortcuts = savedShortcuts.update(id = item.id, online = shouldOnline)
|
||||
}
|
||||
}
|
||||
snackbar.show("ADB 已连接")
|
||||
}
|
||||
|
||||
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
|
||||
@@ -643,16 +571,12 @@ fun DeviceTabPage(
|
||||
adbConnected = true
|
||||
statusLine = "$host:$port"
|
||||
logEvent("ADB 自动重连成功: $host:$port")
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 自动重连成功")
|
||||
}
|
||||
snackbar.show("ADB 自动重连成功")
|
||||
} catch (e: Exception) {
|
||||
disconnectAdbConnection()
|
||||
statusLine = "ADB 连接断开"
|
||||
logEvent("ADB 自动重连失败: $e", Log.ERROR)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 自动重连失败")
|
||||
}
|
||||
snackbar.show("ADB 自动重连失败")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -690,9 +614,7 @@ fun DeviceTabPage(
|
||||
runAutoAdbConnect(target.host, target.port)
|
||||
adbConnected = true
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
online = true
|
||||
host = target.host, port = target.port,
|
||||
)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
@@ -732,10 +654,8 @@ fun DeviceTabPage(
|
||||
}?.port
|
||||
if (portToReplace != null) {
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = discoveredHost,
|
||||
port = portToReplace,
|
||||
host = discoveredHost, port = portToReplace,
|
||||
newPort = discoveredPort,
|
||||
online = false
|
||||
)
|
||||
logEvent(
|
||||
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
|
||||
@@ -751,9 +671,7 @@ fun DeviceTabPage(
|
||||
runAutoAdbConnect(discoveredHost, discoveredPort)
|
||||
adbConnected = true
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = discoveredHost,
|
||||
port = discoveredPort,
|
||||
online = true
|
||||
host = discoveredHost, port = discoveredPort,
|
||||
)
|
||||
handleAdbConnected(discoveredHost, discoveredPort)
|
||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||
@@ -779,13 +697,13 @@ fun DeviceTabPage(
|
||||
sessionInfoWidth = sessionInfo?.width ?: 0
|
||||
sessionInfoHeight = sessionInfo?.height ?: 0
|
||||
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
|
||||
sessionInfoCodec = sessionInfo?.codecName.orEmpty()
|
||||
sessionInfoCodec = sessionInfo?.codec
|
||||
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
|
||||
} else {
|
||||
sessionInfoWidth = 0
|
||||
sessionInfoHeight = 0
|
||||
sessionInfoDeviceName = ""
|
||||
sessionInfoCodec = ""
|
||||
sessionInfoCodec = null
|
||||
sessionInfoControlEnabled = false
|
||||
}
|
||||
onSessionStartedChange(sessionInfo != null)
|
||||
@@ -799,28 +717,6 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
if (editingDevice != null) {
|
||||
DeviceEditorScreen(
|
||||
contentPadding = contentPadding,
|
||||
device = editingDevice!!,
|
||||
onSave = { updated ->
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
id = editingDevice!!.id,
|
||||
host = updated.host,
|
||||
port = updated.port,
|
||||
online = updated.online,
|
||||
)
|
||||
editingDevice = null
|
||||
},
|
||||
onDelete = {
|
||||
savedShortcuts = savedShortcuts.remove(id = editingDevice!!.id)
|
||||
editingDevice = null
|
||||
},
|
||||
onBack = { editingDevice = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
|
||||
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
|
||||
|
||||
@@ -843,27 +739,38 @@ fun DeviceTabPage(
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device ->
|
||||
val host = device.host
|
||||
val port = device.port
|
||||
val isConnectedTarget = adbConnected
|
||||
&& currentTarget?.host == host
|
||||
&& currentTarget.port == port
|
||||
|
||||
DeviceTile(
|
||||
device = device,
|
||||
actionText = if (!isConnectedTarget) "连接" else "断开",
|
||||
item {
|
||||
DeviceTileList(
|
||||
devices = savedShortcuts,
|
||||
isConnected = { device ->
|
||||
adbConnected
|
||||
&& currentTarget?.host == device.host
|
||||
&& currentTarget.port == device.port
|
||||
},
|
||||
actionEnabled = !busy && !adbConnecting,
|
||||
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
|
||||
onLongPress = { editingDevice = device },
|
||||
onContentClick = {
|
||||
scope.launch {
|
||||
snack.showSnackbar("长按可编辑设备")
|
||||
actionInProgress = { device ->
|
||||
adbConnecting && activeDeviceActionId == device.id
|
||||
},
|
||||
editingDeviceId = editingDeviceId,
|
||||
onClick = {},
|
||||
onLongClick = { device ->
|
||||
val connected = adbConnected
|
||||
&& currentTarget?.host == device.host
|
||||
&& currentTarget.port == device.port
|
||||
if (connected) {
|
||||
snackbar.show("无法修改已连接的设备")
|
||||
} else {
|
||||
editingDeviceId = device.id
|
||||
}
|
||||
},
|
||||
onAction = {
|
||||
onAction = { device ->
|
||||
haptics.contextClick()
|
||||
if (!isConnectedTarget) {
|
||||
val host = device.host
|
||||
val port = device.port
|
||||
val connected = adbConnected
|
||||
&& currentTarget?.host == host
|
||||
&& currentTarget.port == port
|
||||
if (!connected) {
|
||||
runAdbConnect(
|
||||
"连接 ADB",
|
||||
onStarted = { activeDeviceActionId = device.id },
|
||||
@@ -873,17 +780,15 @@ fun DeviceTabPage(
|
||||
try {
|
||||
connectWithTimeout(host, port)
|
||||
adbConnected = true
|
||||
isQuickConnected = false // 标记为快速设备连接
|
||||
isQuickConnected = false
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = host, port = port, online = true
|
||||
host = host, port = port,
|
||||
)
|
||||
handleAdbConnected(host, port)
|
||||
} catch (e: Exception) {
|
||||
statusLine = "ADB 连接失败"
|
||||
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 连接失败")
|
||||
}
|
||||
snackbar.show("ADB 连接失败")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -902,11 +807,26 @@ 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) {
|
||||
// "快速连接"
|
||||
item {
|
||||
QuickConnectCard(
|
||||
input = qdBundle.quickConnectInput,
|
||||
onValueChange = {
|
||||
@@ -923,9 +843,7 @@ fun DeviceTabPage(
|
||||
"SavedShortcuts",
|
||||
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
|
||||
)
|
||||
scope.launch {
|
||||
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
|
||||
}
|
||||
snackbar.show("已添加设备: ${target.host}:${target.port}")
|
||||
},
|
||||
onConnect = {
|
||||
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
|
||||
@@ -941,22 +859,21 @@ fun DeviceTabPage(
|
||||
adbConnected = true
|
||||
isQuickConnected = true // 标记为快速连接
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
online = true
|
||||
host = target.host, port = target.port,
|
||||
)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
} catch (e: Exception) {
|
||||
statusLine = "ADB 连接失败"
|
||||
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||
scope.launch {
|
||||
snack.showSnackbar("ADB 连接失败")
|
||||
}
|
||||
snackbar.show("ADB 连接失败")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
SectionSmallTitle("无线配对")
|
||||
}
|
||||
|
||||
item {
|
||||
SectionSmallTitle("无线配对", showLeadingSpacer = false)
|
||||
// "使用配对码配对设备"
|
||||
PairingCard(
|
||||
busy = busy,
|
||||
@@ -980,27 +897,28 @@ fun DeviceTabPage(
|
||||
if (ok) "配对成功" else "配对失败",
|
||||
if (ok) Log.INFO else Log.ERROR
|
||||
)
|
||||
scope.launch {
|
||||
snack.showSnackbar(if (ok) "配对成功" else "配对失败")
|
||||
}
|
||||
snackbar.show(if (ok) "配对成功" else "配对失败")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (adbConnected) {
|
||||
item {
|
||||
SectionSmallTitle("Scrcpy", showLeadingSpacer = false)
|
||||
ConfigPanel(
|
||||
busy = busy,
|
||||
snack = snack,
|
||||
snackbar = snackbar,
|
||||
audioForwardingSupported = audioForwardingSupported,
|
||||
cameraMirroringSupported = cameraMirroringSupported,
|
||||
adbConnecting = adbConnecting,
|
||||
isQuickConnected = isQuickConnected,
|
||||
onOpenAdvanced = onOpenAdvancedPage,
|
||||
onStartStopHaptic = { haptics.contextClick() },
|
||||
onStart = {
|
||||
runBusy("启动 scrcpy") {
|
||||
val options = scrcpyOptions.toClientOptions(soBundleShared)
|
||||
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
|
||||
val session = scrcpy.start(options)
|
||||
sessionInfo = session.copy(
|
||||
host = currentTargetHost,
|
||||
@@ -1010,8 +928,8 @@ fun DeviceTabPage(
|
||||
@SuppressLint("DefaultLocale")
|
||||
val videoDetail =
|
||||
if (!options.video) "off"
|
||||
else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default"
|
||||
else "${session.codecName} ${session.width}x${session.height} " +
|
||||
else if (videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
|
||||
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
|
||||
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
|
||||
|
||||
val audioDetail =
|
||||
@@ -1025,9 +943,7 @@ fun DeviceTabPage(
|
||||
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
|
||||
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
|
||||
)
|
||||
scope.launch {
|
||||
snack.showSnackbar("scrcpy 已启动")
|
||||
}
|
||||
snackbar.show("scrcpy 已启动")
|
||||
}
|
||||
},
|
||||
onStop = {
|
||||
@@ -1036,9 +952,7 @@ fun DeviceTabPage(
|
||||
sessionInfo = null
|
||||
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
|
||||
logEvent("scrcpy 已停止")
|
||||
scope.launch {
|
||||
snack.showSnackbar("scrcpy 已停止")
|
||||
}
|
||||
snackbar.show("scrcpy 已停止")
|
||||
}
|
||||
},
|
||||
sessionInfo = sessionInfo,
|
||||
@@ -1094,8 +1008,9 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
if (EventLogger.hasLogs()) item {
|
||||
Spacer(Modifier.height(UiSpacing.PageItem))
|
||||
if (EventLogger.hasLogs()) {
|
||||
item {
|
||||
SectionSmallTitle("日志", showLeadingSpacer = false)
|
||||
Card {
|
||||
TextField(
|
||||
value = EventLogger.eventLog.joinToString(separator = "\n"),
|
||||
@@ -1105,8 +1020,9 @@ fun DeviceTabPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
|
||||
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,20 +1047,20 @@ private fun DeviceMenuPopup(
|
||||
text = "快速设备排序",
|
||||
optionSize = 3,
|
||||
index = 0,
|
||||
onClick = onReorderDevices,
|
||||
onSelectedIndexChange = { onReorderDevices() },
|
||||
)
|
||||
DeviceMenuPopupItem(
|
||||
text = "虚拟按钮排序",
|
||||
optionSize = 3,
|
||||
index = 1,
|
||||
onClick = onOpenVirtualButtonOrder,
|
||||
onSelectedIndexChange = { onOpenVirtualButtonOrder() },
|
||||
)
|
||||
DeviceMenuPopupItem(
|
||||
text = "清空日志",
|
||||
optionSize = 3,
|
||||
index = 2,
|
||||
enabled = canClearLogs,
|
||||
onClick = onClearLogs,
|
||||
onSelectedIndexChange = { onClearLogs() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1156,8 +1072,7 @@ private fun DeviceMenuPopupItem(
|
||||
optionSize: Int,
|
||||
index: Int,
|
||||
enabled: Boolean = true,
|
||||
// TODO: (Int) -> Unit
|
||||
onClick: () -> Unit,
|
||||
onSelectedIndexChange: (Int) -> Unit,
|
||||
) {
|
||||
if (enabled) {
|
||||
DropdownImpl(
|
||||
@@ -1165,7 +1080,7 @@ private fun DeviceMenuPopupItem(
|
||||
optionSize = optionSize,
|
||||
isSelected = false,
|
||||
index = index,
|
||||
onSelectedIndexChange = { onClick() },
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -32,7 +32,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -53,6 +55,7 @@ fun FullscreenControlScreen(
|
||||
|
||||
val haptics = rememberAppHaptics()
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
val activity = remember(context) { context as? Activity }
|
||||
|
||||
@@ -73,7 +76,7 @@ fun FullscreenControlScreen(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
appSettings.saveBundle(asBundleLatest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Devices
|
||||
@@ -42,16 +44,20 @@ import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberDecoratedNavEntries
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -61,6 +67,7 @@ import top.yukonga.miuix.kmp.basic.NavigationBarItem
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
@@ -84,6 +91,7 @@ fun MainScreen() {
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
val activity = remember(context) { context as? Activity }
|
||||
val initialOrientation = remember(activity) {
|
||||
@@ -101,11 +109,14 @@ fun MainScreen() {
|
||||
val adbService = remember(appContext) {
|
||||
NativeAdbService(appContext)
|
||||
}
|
||||
val scrcpy = remember(appContext, adbService) {
|
||||
Scrcpy(appContext, adbService)
|
||||
}
|
||||
|
||||
val snackHostState = remember { SnackbarHostState() }
|
||||
val snackbarController = remember(scope, snackHostState) {
|
||||
SnackbarController(
|
||||
scope = scope,
|
||||
hostState = snackHostState,
|
||||
)
|
||||
}
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
val tabs = remember { MainBottomTabDestination.entries }
|
||||
val pagerState = rememberPagerState(
|
||||
@@ -137,6 +148,75 @@ fun MainScreen() {
|
||||
// 2) switch tab back to Device
|
||||
// 3) double-back to exit and disconnect adb/scrcpy
|
||||
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||
LaunchedEffect(asBundleShared) {
|
||||
if (asBundle != asBundleShared) {
|
||||
asBundle = asBundleShared
|
||||
}
|
||||
}
|
||||
LaunchedEffect(asBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (asBundle != asBundleSharedLatest) {
|
||||
appSettings.saveBundle(asBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskScope.launch {
|
||||
appSettings.saveBundle(asBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
|
||||
val qdBundleLatest by rememberUpdatedState(qdBundle)
|
||||
LaunchedEffect(qdBundleShared) {
|
||||
if (qdBundle != qdBundleShared) {
|
||||
qdBundle = qdBundleShared
|
||||
}
|
||||
}
|
||||
LaunchedEffect(qdBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (qdBundle != qdBundleSharedLatest) {
|
||||
quickDevices.saveBundle(qdBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskScope.launch {
|
||||
quickDevices.saveBundle(qdBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val customServerUri = asBundle.customServerUri
|
||||
.ifBlank { null }
|
||||
val customServerVersion = asBundle.customServerVersion
|
||||
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
|
||||
val serverRemotePath = asBundle.serverRemotePath
|
||||
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
|
||||
val scrcpy = remember(
|
||||
appContext,
|
||||
adbService,
|
||||
customServerUri,
|
||||
customServerVersion,
|
||||
serverRemotePath,
|
||||
) {
|
||||
Scrcpy(
|
||||
appContext = appContext,
|
||||
adbService = adbService,
|
||||
customServerUri = customServerUri,
|
||||
serverVersion = customServerVersion,
|
||||
serverRemotePath = serverRemotePath,
|
||||
)
|
||||
}
|
||||
|
||||
fun handleBackNavigation() {
|
||||
if (rootBackStack.size > 1) {
|
||||
popRoot()
|
||||
@@ -186,75 +266,12 @@ fun MainScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
|
||||
val asBundleLatest by rememberUpdatedState(asBundle)
|
||||
LaunchedEffect(asBundleShared) {
|
||||
if (asBundle != asBundleShared) {
|
||||
asBundle = asBundleShared
|
||||
}
|
||||
}
|
||||
LaunchedEffect(asBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (asBundle != asBundleSharedLatest) {
|
||||
appSettings.saveBundle(asBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
appSettings.saveBundle(asBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
|
||||
val qdBundleLatest by rememberUpdatedState(qdBundle)
|
||||
LaunchedEffect(qdBundleShared) {
|
||||
if (qdBundle != qdBundleShared) {
|
||||
qdBundle = qdBundleShared
|
||||
}
|
||||
}
|
||||
LaunchedEffect(qdBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (qdBundle != qdBundleSharedLatest) {
|
||||
quickDevices.saveBundle(qdBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
quickDevices.saveBundle(qdBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sessionStarted by remember { mutableStateOf(false) }
|
||||
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
|
||||
var fullscreenOrientation by rememberSaveable {
|
||||
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
|
||||
}
|
||||
|
||||
var savedShortcuts by remember {
|
||||
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
||||
}
|
||||
|
||||
LaunchedEffect(qdBundle.quickDevicesList) {
|
||||
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
|
||||
}
|
||||
LaunchedEffect(savedShortcuts) {
|
||||
val serialized = savedShortcuts.marshalToString()
|
||||
if (serialized != qdBundle.quickDevicesList) {
|
||||
qdBundle = qdBundle.copy(quickDevicesList = serialized)
|
||||
}
|
||||
}
|
||||
|
||||
val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet)
|
||||
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||
|
||||
val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming
|
||||
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
|
||||
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
|
||||
@@ -336,7 +353,7 @@ fun MainScreen() {
|
||||
nativeCore = nativeCore,
|
||||
adbService = adbService,
|
||||
scrcpy = scrcpy,
|
||||
snack = snackHostState,
|
||||
snackbar = snackbarController,
|
||||
scrollBehavior = devicesPageScrollBehavior,
|
||||
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
|
||||
onSessionStartedChange = { sessionStarted = it },
|
||||
@@ -357,7 +374,7 @@ fun MainScreen() {
|
||||
|
||||
MainBottomTabDestination.Settings -> SettingsScreen(
|
||||
scrollBehavior = settingsPageScrollBehavior,
|
||||
snack = snackHostState,
|
||||
snackbar = snackbarController,
|
||||
onOpenReorderDevices = { showReorderDevices = true },
|
||||
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
|
||||
onPickServer = {
|
||||
@@ -382,10 +399,10 @@ fun MainScreen() {
|
||||
}
|
||||
|
||||
entry(RootScreen.Advanced) {
|
||||
AdvancedConfigScreen(
|
||||
ScrcpyAllOptionsScreen(
|
||||
onBack = ::popRoot,
|
||||
scrollBehavior = advancedPageScrollBehavior,
|
||||
snack = snackHostState,
|
||||
snackbar = snackbarController,
|
||||
scrcpy = scrcpy,
|
||||
)
|
||||
}
|
||||
@@ -416,6 +433,13 @@ fun MainScreen() {
|
||||
entryProvider = rootEntryProvider,
|
||||
)
|
||||
|
||||
val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
|
||||
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
|
||||
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
|
||||
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
|
||||
}
|
||||
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
|
||||
|
||||
MiuixTheme(controller = themeController) {
|
||||
NavDisplay(
|
||||
entries = rootEntries,
|
||||
|
||||
@@ -19,6 +19,9 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
|
||||
@@ -29,6 +32,7 @@ fun ReorderDevicesScreen(
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
|
||||
@@ -47,7 +51,7 @@ fun ReorderDevicesScreen(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
quickDevices.saveBundle(qdBundleLatest)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,6 @@ fun ReorderDevicesScreen(
|
||||
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
|
||||
},
|
||||
).invoke()
|
||||
Spacer(Modifier.height(UiSpacing.BottomSheetBottom))
|
||||
Spacer(Modifier.height(UiSpacing.SheetBottom))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
@@ -67,6 +70,7 @@ internal fun VirtualButtonOrderPage(
|
||||
scrollBehavior: ScrollBehavior,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
val asBundleShared by appSettings.bundleState.collectAsState()
|
||||
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||
@@ -85,7 +89,7 @@ internal fun VirtualButtonOrderPage(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
appSettings.saveBundle(asBundleLatest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -122,7 +123,7 @@ private fun SliderInputDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissFinished = onDismissFinished,
|
||||
content = {
|
||||
var text by remember(initialValue) { mutableStateOf(initialValue) }
|
||||
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
|
||||
|
||||
SuperTextField(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
|
||||
@@ -11,23 +11,15 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||
|
||||
// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改
|
||||
|
||||
// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项
|
||||
|
||||
// TODO: 禁用配置项验证, 包括不隐藏冲突配置项
|
||||
|
||||
// TODO: 配置切换
|
||||
|
||||
// TODO: 参数预览框可编辑
|
||||
|
||||
data class ClientOptions(
|
||||
// var serial: String = "", // to server
|
||||
|
||||
// --crop=width:height:x:y
|
||||
var crop: String = "", // to server
|
||||
|
||||
// TODO
|
||||
// TODO: implement
|
||||
// --record
|
||||
var recordFilename: String = "",
|
||||
|
||||
@@ -236,7 +228,28 @@ data class ClientOptions(
|
||||
}
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
fun fix(): ClientOptions {
|
||||
when(videoSource) {
|
||||
VideoSource.DISPLAY -> {
|
||||
cameraId = ""
|
||||
cameraFacing = CameraFacing.ANY
|
||||
cameraSize = ""
|
||||
cameraAr = ""
|
||||
cameraFps = 0u
|
||||
cameraHighSpeed = false
|
||||
}
|
||||
VideoSource.CAMERA -> {
|
||||
displayId = 0
|
||||
maxSize = 0u
|
||||
maxFps = ""
|
||||
newDisplay = ""
|
||||
crop = ""
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun validate(): ClientOptions {
|
||||
if (!video) {
|
||||
videoPlayback = false
|
||||
powerOn = false
|
||||
@@ -494,6 +507,8 @@ data class ClientOptions(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
fun toServerParams(scid: UInt): ServerParams {
|
||||
|
||||
@@ -10,6 +10,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -49,7 +52,7 @@ class Scrcpy(
|
||||
private val adbService: NativeAdbService,
|
||||
private val serverAsset: String = DEFAULT_SERVER_ASSET,
|
||||
private val customServerUri: String? = null,
|
||||
private val serverVersion: String = "3.3.4",
|
||||
private val serverVersion: String = DEFAULT_SERVER_VERSION,
|
||||
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
|
||||
) {
|
||||
private val session = Session(adbService)
|
||||
@@ -64,36 +67,29 @@ class Scrcpy(
|
||||
@Volatile
|
||||
private var audioPlayer: ScrcpyAudioPlayer? = null
|
||||
|
||||
// Cached encoder and camera size data
|
||||
private val _videoEncoders = mutableListOf<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()
|
||||
val listings = Listings()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Scrcpy"
|
||||
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
||||
|
||||
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
|
||||
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4"
|
||||
const val DEFAULT_SERVER_VERSION = "3.3.4"
|
||||
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
||||
|
||||
// Regex patterns for parsing server output
|
||||
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
|
||||
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
|
||||
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""")
|
||||
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""")
|
||||
private val VIDEO_ENCODER_TYPE_REGEX =
|
||||
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""")
|
||||
private val AUDIO_ENCODER_TYPE_REGEX =
|
||||
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""")
|
||||
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
|
||||
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
|
||||
private const val PREVIEW_LINES = 32
|
||||
private val VIDEO_ENCODER_INFO_REGEX =
|
||||
Regex("""--video-codec=(\S+)\s+--video-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
|
||||
private val AUDIO_ENCODER_INFO_REGEX =
|
||||
Regex("""--audio-codec=(\S+)\s+--audio-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
|
||||
private val DISPLAY_REGEX =
|
||||
Regex("""--display-id=(\d+)\s+\((\d+)x(\d+)\)""")
|
||||
private val CAMERA_SIZE_REGEX =
|
||||
Regex("""\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\b""")
|
||||
private val CAMERA_INFO_REGEX =
|
||||
Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""")
|
||||
private val APP_REGEX =
|
||||
Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE)
|
||||
|
||||
fun generateScid(): UInt {
|
||||
// Only use 31 bits to avoid issues with signed values on the Java-side
|
||||
@@ -169,7 +165,7 @@ class Scrcpy(
|
||||
|
||||
Log.i(
|
||||
TAG, "start(): Session started successfully - device=${info.deviceName}, " +
|
||||
"video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " +
|
||||
"video=${if (options.video) "${info.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " +
|
||||
"audio=${if (options.audio) options.audioCodec.string else "off"}, " +
|
||||
"control=${options.control}"
|
||||
)
|
||||
@@ -218,8 +214,6 @@ class Scrcpy(
|
||||
|
||||
fun getCurrentSession(): Session.SessionInfo? = currentSession
|
||||
|
||||
fun getLastServerCommand(): String? = session.getLastServerCommand()
|
||||
|
||||
sealed class ListResult {
|
||||
data class Encoders(
|
||||
val videoEncoders: List<String>,
|
||||
@@ -229,67 +223,176 @@ class Scrcpy(
|
||||
val rawOutput: String = "",
|
||||
) : 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(
|
||||
val sizes: List<String>,
|
||||
val rawOutput: String = "",
|
||||
) : ListResult()
|
||||
|
||||
data class Apps(
|
||||
val apps: List<AppInfo>,
|
||||
val rawOutput: String = "",
|
||||
) : ListResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh encoder lists from the device.
|
||||
* Results are cached and can be accessed via videoEncoders, audioEncoders, etc.
|
||||
*
|
||||
* @throws Exception if the operation fails
|
||||
*/
|
||||
suspend fun refreshEncoders() {
|
||||
val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders
|
||||
_videoEncoders.clear()
|
||||
_videoEncoders.addAll(result.videoEncoders)
|
||||
_audioEncoders.clear()
|
||||
_audioEncoders.addAll(result.audioEncoders)
|
||||
_videoEncoderTypes.clear()
|
||||
_videoEncoderTypes.putAll(result.videoEncoderTypes)
|
||||
_audioEncoderTypes.clear()
|
||||
_audioEncoderTypes.putAll(result.audioEncoderTypes)
|
||||
inner class Listings {
|
||||
private val encodersMutex = Mutex()
|
||||
private val displaysMutex = Mutex()
|
||||
private val camerasMutex = Mutex()
|
||||
private val cameraSizesMutex = Mutex()
|
||||
private val appsMutex = Mutex()
|
||||
|
||||
Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}")
|
||||
@Volatile
|
||||
private var cachedVideoEncoders: List<EncoderInfo>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedAudioEncoders: List<EncoderInfo>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedDisplays: List<DisplayInfo>? = null
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh camera sizes from the device.
|
||||
* Results are cached and can be accessed via cameraSizes.
|
||||
*
|
||||
* @throws Exception if the operation fails
|
||||
*/
|
||||
suspend fun refreshCameraSizes() {
|
||||
val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes
|
||||
_cameraSizes.clear()
|
||||
_cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size ->
|
||||
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
|
||||
}))
|
||||
|
||||
Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}")
|
||||
})
|
||||
cachedCameraSizes = parsed
|
||||
logListPreview(
|
||||
list = ListOptions.CAMERA_SIZES,
|
||||
countSummary = "sizes=${parsed.size}",
|
||||
output = output,
|
||||
)
|
||||
parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) {
|
||||
require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" }
|
||||
|
||||
val serverJar = if (customServerUri.isNullOrBlank()) {
|
||||
extractAssetToCache(serverAsset)
|
||||
} else {
|
||||
extractUriToCache(customServerUri.toUri())
|
||||
}
|
||||
|
||||
// Push server jar to device
|
||||
adbService.push(serverJar.toPath(), serverRemotePath)
|
||||
|
||||
val scid = generateScid()
|
||||
|
||||
// Create ClientOptions for listing
|
||||
val options = ClientOptions(
|
||||
video = false,
|
||||
audio = false,
|
||||
@@ -297,10 +400,7 @@ class Scrcpy(
|
||||
cleanUp = false,
|
||||
list = list,
|
||||
)
|
||||
|
||||
val serverParams = options.toServerParams(scid)
|
||||
|
||||
// Build server command
|
||||
val serverCommand = serverParams.build(
|
||||
"CLASSPATH=$serverRemotePath",
|
||||
"app_process",
|
||||
@@ -310,121 +410,133 @@ class Scrcpy(
|
||||
)
|
||||
|
||||
Log.i(TAG, "listOptions(): cmd=$serverCommand")
|
||||
|
||||
// Execute shell command and capture output (merge stderr into stdout)
|
||||
val output = adbService.shell("$serverCommand 2>&1")
|
||||
|
||||
// Parse output based on list option
|
||||
return@withContext when (list) {
|
||||
ListOptions.NULL -> {
|
||||
throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
|
||||
adbService.shell("$serverCommand 2>&1")
|
||||
}
|
||||
|
||||
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",
|
||||
private fun logListPreview(list: ListOptions, countSummary: String, output: String) {
|
||||
val preview = output.lineSequence().take(32).joinToString("\n")
|
||||
Log.i(TAG, "listOptions($list): parsed $countSummary, outputPreview=\n$preview")
|
||||
}
|
||||
|
||||
private fun 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(),
|
||||
)
|
||||
ListResult.Encoders(
|
||||
videoEncoders = parsed.videoEncoders,
|
||||
audioEncoders = parsed.audioEncoders,
|
||||
videoEncoderTypes = parsed.videoEncoderTypes,
|
||||
audioEncoderTypes = parsed.audioEncoderTypes,
|
||||
rawOutput = output,
|
||||
)
|
||||
}
|
||||
|
||||
ListOptions.DISPLAYS -> {
|
||||
throw Exception("TODO")
|
||||
return displays.toList()
|
||||
}
|
||||
|
||||
ListOptions.CAMERAS -> {
|
||||
throw Exception("TODO")
|
||||
}
|
||||
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() }
|
||||
|
||||
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",
|
||||
cameras.add(
|
||||
CameraInfo(
|
||||
id = match.groupValues[1],
|
||||
facing = CameraFacing.fromString(facing),
|
||||
activeSize = activeSize,
|
||||
fps = fpsValues.map(Int::toUShort),
|
||||
)
|
||||
ListResult.CameraSizes(
|
||||
sizes = parsed.sizes,
|
||||
rawOutput = output,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalArgumentException("Unsupported list option: $list")
|
||||
}
|
||||
}
|
||||
return cameras.toList()
|
||||
}
|
||||
|
||||
private fun parseEncoderLists(output: String): ParsedEncoders {
|
||||
val video = LinkedHashSet<String>()
|
||||
val audio = LinkedHashSet<String>()
|
||||
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 parseCameraSizes(output: String): List<String> {
|
||||
val sizes = LinkedHashSet<String>()
|
||||
CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
|
||||
sizes.add(match.groupValues[1])
|
||||
}
|
||||
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
|
||||
sizes.add(match.groupValues[1])
|
||||
}
|
||||
return ParsedCameraSizes(sizes = sizes.toList())
|
||||
return sizes.toList()
|
||||
}
|
||||
|
||||
private data class ParsedEncoders(
|
||||
val videoEncoders: List<String>,
|
||||
val audioEncoders: List<String>,
|
||||
val videoEncoderTypes: Map<String, String>,
|
||||
val audioEncoderTypes: Map<String, String>,
|
||||
private fun parseApps(output: String): List<AppInfo> {
|
||||
val apps = LinkedHashSet<AppInfo>()
|
||||
APP_REGEX.findAll(output).forEach { match ->
|
||||
apps.add(
|
||||
AppInfo(
|
||||
system = match.groupValues[1] == "*",
|
||||
label = match.groupValues[2].trim(),
|
||||
packageName = match.groupValues[3].trim(),
|
||||
)
|
||||
)
|
||||
}
|
||||
return apps.toList()
|
||||
}
|
||||
|
||||
data class EncoderInfo(
|
||||
val codec: Codec,
|
||||
val id: String,
|
||||
val type: EncoderType,
|
||||
val isVendor: Boolean,
|
||||
val aliasOf: String? = null,
|
||||
)
|
||||
|
||||
private data class ParsedCameraSizes(
|
||||
val sizes: List<String>,
|
||||
data class CameraInfo(
|
||||
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(
|
||||
@@ -502,8 +614,6 @@ class Scrcpy(
|
||||
@Volatile
|
||||
private var audioReaderThread: Thread? = null
|
||||
|
||||
@Volatile
|
||||
private var lastServerCommand: String? = null
|
||||
private val serverLogBuffer = ArrayDeque<String>()
|
||||
|
||||
suspend fun start(
|
||||
@@ -516,7 +626,6 @@ class Scrcpy(
|
||||
val socketName = socketNameFor(scid.toInt())
|
||||
|
||||
try {
|
||||
lastServerCommand = serverCommand
|
||||
val serverStream = adbService.openShellStream(serverCommand)
|
||||
val serverLogThread = startServerLogThread(serverStream, socketName)
|
||||
Thread.sleep(SERVER_BOOT_DELAY_MS)
|
||||
@@ -567,27 +676,58 @@ class Scrcpy(
|
||||
}
|
||||
|
||||
val deviceName = readDeviceName(firstInput)
|
||||
val audioCodecId =
|
||||
if (options.audio) audioCodecIdFromName(options.audioCodec.string)
|
||||
else 0
|
||||
val codecId: Int
|
||||
val audioCodecId = if (options.audio) {
|
||||
val aInput = checkNotNull(audioInput)
|
||||
when (val streamCodecId = aInput.readInt()) {
|
||||
AUDIO_DISABLED -> {
|
||||
Log.w(TAG, "audio disabled by server")
|
||||
if (options.requireAudio) {
|
||||
throw IllegalStateException(
|
||||
"Audio is required but was disabled by the server"
|
||||
)
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
AUDIO_ERROR -> {
|
||||
Log.e(TAG, "audio stream configuration error from server")
|
||||
if (options.requireAudio) {
|
||||
throw IllegalStateException(
|
||||
"Audio is required but the server failed to configure audio capture"
|
||||
)
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"audio stream codec=0x${streamCodecId.toUInt().toString(16)}"
|
||||
)
|
||||
streamCodecId
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val videoCodecId: Int
|
||||
val width: Int
|
||||
val height: Int
|
||||
if (options.video) {
|
||||
val vInput = checkNotNull(videoInput)
|
||||
codecId = vInput.readInt()
|
||||
videoCodecId = vInput.readInt()
|
||||
width = vInput.readInt()
|
||||
height = vInput.readInt()
|
||||
} else {
|
||||
codecId = 0
|
||||
videoCodecId = 0
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
val sessionInfo = SessionInfo(
|
||||
deviceName = deviceName,
|
||||
codecId = codecId,
|
||||
codecName = codecName(codecId),
|
||||
codecId = videoCodecId,
|
||||
codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO),
|
||||
width = width,
|
||||
height = height,
|
||||
audioCodecId = audioCodecId,
|
||||
@@ -669,9 +809,7 @@ class Scrcpy(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearVideoConsumer() = mutex.withLock {
|
||||
videoConsumer = null
|
||||
}
|
||||
suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null }
|
||||
|
||||
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
|
||||
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
|
||||
@@ -682,31 +820,6 @@ class Scrcpy(
|
||||
|
||||
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
|
||||
try {
|
||||
val streamCodecId = try {
|
||||
aInput.readInt()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "audio codec header read failed", e)
|
||||
return@thread
|
||||
}
|
||||
when (streamCodecId) {
|
||||
AUDIO_DISABLED -> {
|
||||
Log.w(TAG, "audio disabled by server")
|
||||
return@thread
|
||||
}
|
||||
|
||||
AUDIO_ERROR -> {
|
||||
Log.e(TAG, "audio stream configuration error from server")
|
||||
return@thread
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.i(
|
||||
TAG,
|
||||
"audio stream codec=0x${streamCodecId.toUInt().toString(16)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
while (activeSession === session && !aStream.closed) {
|
||||
try {
|
||||
val ptsAndFlags = aInput.readLong()
|
||||
@@ -742,12 +855,14 @@ class Scrcpy(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAudioConsumer() = mutex.withLock {
|
||||
audioConsumer = null
|
||||
}
|
||||
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null }
|
||||
|
||||
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
|
||||
mutex.withLock {
|
||||
suspend fun injectKeycode(
|
||||
action: Int,
|
||||
keycode: Int,
|
||||
repeat: Int = 0,
|
||||
metaState: Int = 0,
|
||||
) = mutex.withLock {
|
||||
try {
|
||||
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
|
||||
} catch (e: IllegalStateException) {
|
||||
@@ -798,7 +913,7 @@ class Scrcpy(
|
||||
screenHeight: Int,
|
||||
hScroll: Float,
|
||||
vScroll: Float,
|
||||
buttons: Int
|
||||
buttons: Int,
|
||||
) = mutex.withLock {
|
||||
try {
|
||||
requireControlWriter().injectScroll(
|
||||
@@ -815,9 +930,9 @@ class Scrcpy(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
|
||||
suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
|
||||
try {
|
||||
requireControlWriter().pressBackOrScreenOn(action)
|
||||
requireControlWriter().pressBackOrTurnScreenOn(action)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e)
|
||||
}
|
||||
@@ -865,8 +980,6 @@ class Scrcpy(
|
||||
|
||||
fun isStarted(): Boolean = activeSession != null
|
||||
|
||||
fun getLastServerCommand(): String? = lastServerCommand
|
||||
|
||||
private fun requireControlWriter(): ControlWriter {
|
||||
val session = activeSession
|
||||
?: throw IllegalStateException("scrcpy control channel not available")
|
||||
@@ -948,27 +1061,10 @@ class Scrcpy(
|
||||
return buffer.copyOf(length).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun codecName(codecId: Int) =
|
||||
when (codecId) {
|
||||
VIDEO_CODEC_H264 -> "h264"
|
||||
VIDEO_CODEC_H265 -> "h265"
|
||||
VIDEO_CODEC_AV1 -> "av1"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
private fun audioCodecIdFromName(name: String) =
|
||||
when (name.lowercase()) {
|
||||
"opus" -> AUDIO_CODEC_OPUS
|
||||
"aac" -> AUDIO_CODEC_AAC
|
||||
"raw" -> AUDIO_CODEC_RAW
|
||||
"flac" -> AUDIO_CODEC_FLAC
|
||||
else -> 0
|
||||
}
|
||||
|
||||
data class SessionInfo(
|
||||
val deviceName: String,
|
||||
val codecId: Int,
|
||||
val codecName: String,
|
||||
val codec: Codec?,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val audioCodecId: Int = 0,
|
||||
@@ -1100,7 +1196,7 @@ class Scrcpy(
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun pressBackOrScreenOn(action: Int) {
|
||||
fun pressBackOrTurnScreenOn(action: Int) {
|
||||
output.writeByte(TYPE_BACK_OR_SCREEN_ON)
|
||||
output.writeByte(action)
|
||||
output.flush()
|
||||
@@ -1151,14 +1247,6 @@ class Scrcpy(
|
||||
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
|
||||
private const val PACKET_PTS_MASK = (1L shl 62) - 1
|
||||
|
||||
private const val VIDEO_CODEC_H264 = 0x68323634
|
||||
private const val VIDEO_CODEC_H265 = 0x68323635
|
||||
private const val VIDEO_CODEC_AV1 = 0x00617631
|
||||
private const val AUDIO_CODEC_OPUS = 0x6f707573
|
||||
private const val AUDIO_CODEC_AAC = 0x00616163
|
||||
private const val AUDIO_CODEC_FLAC = 0x666c6163
|
||||
private const val AUDIO_CODEC_RAW = 0x00726177
|
||||
|
||||
private const val AUDIO_DISABLED = 0
|
||||
private const val AUDIO_ERROR = 1
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ data class ServerParams(
|
||||
cmd.add("audio=false")
|
||||
}
|
||||
if (audioBitRate > 0) {
|
||||
if (audioCodec.isLossyAudio()!!) {
|
||||
if (!audioCodec.isLossless) {
|
||||
// 比官方实现多个判断编解码器类型
|
||||
cmd.add("audio_bit_rate=$audioBitRate")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ class Shared {
|
||||
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
|
||||
}
|
||||
|
||||
|
||||
enum class ListOptions(val value: Int) {
|
||||
NULL(0x0),
|
||||
ENCODERS(0x1), // --list-encoders
|
||||
@@ -52,6 +51,11 @@ class Shared {
|
||||
infix fun has(other: ListOptions) = this and other != 0
|
||||
}
|
||||
|
||||
enum class EncoderType(val s: String) {
|
||||
HARDWARE("hw"),
|
||||
SOFTWARE("sw"),
|
||||
}
|
||||
|
||||
enum class LogLevel(val string: String) {
|
||||
VERBOSE("verbose"),
|
||||
DEBUG("debug"),
|
||||
@@ -60,26 +64,27 @@ class Shared {
|
||||
ERROR("error");
|
||||
}
|
||||
|
||||
enum class Codec(val string: String, val displayName: String) {
|
||||
H264("h264", "H.264"), // default, ignore when passing
|
||||
H265("h265", "H.265"),
|
||||
AV1("av1", "AV1"),
|
||||
OPUS("opus", "Opus"), // default, ignore when passing
|
||||
AAC("aac", "AAC"),
|
||||
FLAC("flac", "FLAC"),
|
||||
RAW("raw", "RAW"); // wav raw
|
||||
enum class Codec(
|
||||
val string: String,
|
||||
val displayName: String,
|
||||
val type: Type,
|
||||
val id: Int,
|
||||
val isLossless: Boolean,
|
||||
) {
|
||||
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
||||
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
|
||||
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
|
||||
|
||||
fun isLossyAudio(): Boolean? = when (this) {
|
||||
OPUS, AAC -> true
|
||||
FLAC, RAW -> false
|
||||
else -> null
|
||||
}
|
||||
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
||||
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
|
||||
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
|
||||
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
|
||||
|
||||
enum class Type { VIDEO, AUDIO }
|
||||
|
||||
companion object {
|
||||
val VIDEO = listOf(H264, H265, AV1)
|
||||
val AUDIO = listOf(OPUS, AAC, FLAC, RAW)
|
||||
val VIDEO = entries.filter { it.type == Type.VIDEO }
|
||||
val AUDIO = entries.filter { it.type == Type.AUDIO }
|
||||
|
||||
fun fromString(value: String, type: Type = Type.VIDEO) =
|
||||
entries.find { it.string.equals(value, ignoreCase = true) }
|
||||
@@ -87,6 +92,9 @@ class Shared {
|
||||
Type.VIDEO -> H264
|
||||
Type.AUDIO -> OPUS
|
||||
}
|
||||
|
||||
fun fromId(value: Int, type: Type? = null): Codec? =
|
||||
entries.find { it.id == value && (type == null || it.type == type) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@@ -47,9 +48,13 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
stringPreferencesKey("custom_server_uri"),
|
||||
""
|
||||
)
|
||||
val CUSTOM_SERVER_VERSION = Pair(
|
||||
stringPreferencesKey("custom_server_version"),
|
||||
""
|
||||
)
|
||||
val SERVER_REMOTE_PATH = Pair(
|
||||
stringPreferencesKey("server_remote_path"),
|
||||
"/data/local/tmp/scrcpy-server.jar"
|
||||
Scrcpy.DEFAULT_REMOTE_PATH,
|
||||
)
|
||||
val ADB_KEY_NAME = Pair(
|
||||
stringPreferencesKey("adb_key_name"),
|
||||
@@ -83,6 +88,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
|
||||
// Scrcpy Server Settings
|
||||
val customServerUri by setting(CUSTOM_SERVER_URI)
|
||||
val customServerVersion by setting(CUSTOM_SERVER_VERSION)
|
||||
val serverRemotePath by setting(SERVER_REMOTE_PATH)
|
||||
|
||||
// ADB Settings
|
||||
@@ -102,6 +108,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
val previewVirtualButtonShowText: Boolean,
|
||||
val virtualButtonsLayout: String,
|
||||
val customServerUri: String,
|
||||
val customServerVersion: String,
|
||||
val serverRemotePath: String,
|
||||
val adbKeyName: String,
|
||||
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
|
||||
@@ -120,6 +127,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
|
||||
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
|
||||
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
|
||||
bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion },
|
||||
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
|
||||
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
|
||||
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
|
||||
@@ -139,6 +147,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
|
||||
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
|
||||
customServerUri = preferences.read(CUSTOM_SERVER_URI),
|
||||
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
|
||||
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
|
||||
adbKeyName = preferences.read(ADB_KEY_NAME),
|
||||
adbPairingAutoDiscoverOnDialogOpen = preferences.read(
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -155,7 +156,7 @@ abstract class Settings(
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = asState(pair)
|
||||
|
||||
return remember(state.value) {
|
||||
return rememberSaveable(state.value) {
|
||||
object : MutableState<T> {
|
||||
override var value: T
|
||||
get() = state.value
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -75,10 +76,13 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -88,7 +92,6 @@ import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.CardDefaults
|
||||
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHostState
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
@@ -157,7 +160,7 @@ internal fun StatusCard(
|
||||
),
|
||||
secondSmall = StatusSmallCardSpec(
|
||||
"编解码器",
|
||||
sessionInfo.codecName,
|
||||
sessionInfo.codec?.displayName ?: "null",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -295,7 +298,7 @@ internal fun PreviewCard(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(UiSpacing.CardContent),
|
||||
.padding(UiSpacing.ContentVertical),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -341,7 +344,7 @@ internal fun VirtualButtonCard(
|
||||
onAction = onAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(UiSpacing.CardContent),
|
||||
.padding(UiSpacing.ContentVertical),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -349,9 +352,10 @@ internal fun VirtualButtonCard(
|
||||
@Composable
|
||||
internal fun ConfigPanel(
|
||||
busy: Boolean,
|
||||
snack: SnackbarHostState,
|
||||
snackbar: SnackbarController,
|
||||
audioForwardingSupported: Boolean,
|
||||
cameraMirroringSupported: Boolean,
|
||||
adbConnecting: Boolean,
|
||||
isQuickConnected: Boolean,
|
||||
onOpenAdvanced: () -> Unit,
|
||||
onStartStopHaptic: (() -> Unit)? = null,
|
||||
@@ -360,7 +364,7 @@ internal fun ConfigPanel(
|
||||
sessionInfo: Scrcpy.Session.SessionInfo?,
|
||||
onDisconnect: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
val sessionStarted = sessionInfo != null
|
||||
|
||||
@@ -381,7 +385,7 @@ internal fun ConfigPanel(
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
scrcpyOptions.saveBundle(soBundleLatest)
|
||||
}
|
||||
}
|
||||
@@ -405,7 +409,6 @@ internal fun ConfigPanel(
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
SectionSmallTitle("Scrcpy")
|
||||
Card {
|
||||
SuperSwitch(
|
||||
title = "音频转发",
|
||||
@@ -425,9 +428,7 @@ internal fun ConfigPanel(
|
||||
val codec = Codec.AUDIO[it]
|
||||
soBundle = soBundle.copy(audioCodec = codec.string)
|
||||
if (codec == Codec.FLAC) {
|
||||
scope.launch {
|
||||
snack.showSnackbar("注意:FLAC编解码会引入较大的延迟")
|
||||
}
|
||||
snackbar.show("注意:FLAC编解码会引入较大的延迟")
|
||||
}
|
||||
},
|
||||
enabled = !sessionStarted && soBundle.audio,
|
||||
@@ -436,20 +437,18 @@ internal fun ConfigPanel(
|
||||
SuperSlider(
|
||||
title = "音频码率",
|
||||
summary = "--audio-bit-rate",
|
||||
value = if (soBundle.audioBitRate <= 0) 0f
|
||||
else (ScrcpyPresets.AudioBitRate
|
||||
.indexOfOrNearest(soBundle.audioBitRate / 1000) + 1
|
||||
).toFloat(),
|
||||
value = ScrcpyPresets.AudioBitRate
|
||||
.indexOfOrNearest(soBundle.audioBitRate / 1000)
|
||||
.toFloat(),
|
||||
onValueChange = { value ->
|
||||
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size)
|
||||
val idx = value.roundToInt()
|
||||
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
|
||||
soBundle = soBundle.copy(
|
||||
audioBitRate =
|
||||
if (idx == 0) 0
|
||||
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000
|
||||
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
|
||||
)
|
||||
},
|
||||
valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(),
|
||||
steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0),
|
||||
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
|
||||
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
|
||||
unit = "Kbps",
|
||||
zeroStateText = "默认",
|
||||
displayText = (soBundle.audioBitRate / 1_000).toString(),
|
||||
@@ -475,9 +474,7 @@ internal fun ConfigPanel(
|
||||
val codec = Codec.VIDEO[it]
|
||||
soBundle = soBundle.copy(videoCodec = codec.string)
|
||||
if (codec == Codec.AV1) {
|
||||
scope.launch {
|
||||
snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码")
|
||||
}
|
||||
snackbar.show("注意:绝大部分设备不支持AV1硬件编码")
|
||||
}
|
||||
},
|
||||
enabled = !sessionStarted,
|
||||
@@ -528,8 +525,8 @@ internal fun ConfigPanel(
|
||||
)
|
||||
|
||||
SuperArrow(
|
||||
title = "高级参数",
|
||||
summary = "更多 scrcpy 启动参数",
|
||||
title = "更多参数",
|
||||
summary = "所有 scrcpy 参数",
|
||||
onClick = onOpenAdvanced,
|
||||
enabled = !sessionStarted,
|
||||
)
|
||||
@@ -537,8 +534,8 @@ internal fun ConfigPanel(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = UiSpacing.CardContent)
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
.padding(horizontal = UiSpacing.ContentVertical)
|
||||
.padding(bottom = UiSpacing.ContentVertical),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
) {
|
||||
if (isQuickConnected) TextButton(
|
||||
@@ -601,9 +598,10 @@ private fun PairingDialog(
|
||||
var code by rememberSaveable(showDialog) { mutableStateOf("") }
|
||||
var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
suspend fun doDiscover() {
|
||||
if (onDiscoverTarget == null || discoveringPort || !enabled) return
|
||||
if (!(enabled && onDiscoverTarget != null && !discoveringPort)) return
|
||||
discoveringPort = true
|
||||
val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() }
|
||||
if (found != null) {
|
||||
@@ -619,13 +617,6 @@ private fun PairingDialog(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInputs() {
|
||||
host = ""
|
||||
port = ""
|
||||
code = ""
|
||||
discoveringPort = false
|
||||
}
|
||||
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = "使用配对码配对设备",
|
||||
@@ -634,59 +625,72 @@ private fun PairingDialog(
|
||||
onDismissRequest()
|
||||
},
|
||||
onDismissFinished = {
|
||||
clearInputs()
|
||||
onDismissFinished()
|
||||
},
|
||||
content = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
TextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = "IP 地址",
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
TextField(
|
||||
value = port,
|
||||
onValueChange = { port = it.filter(Char::isDigit) },
|
||||
label = "端口",
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
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,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = UiSpacing.CardContent),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
TextButton(
|
||||
text = if (discoveringPort) "发现中..." else "自动发现",
|
||||
onClick = {
|
||||
if (onDiscoverTarget == null || discoveringPort || !enabled) return@TextButton
|
||||
scope.launch {
|
||||
doDiscover()
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
TextButton(
|
||||
text = if (!discoveringPort) "自动发现" else "发现中...",
|
||||
onClick = {
|
||||
if (enabled && onDiscoverTarget != null && !discoveringPort)
|
||||
scope.launch { doDiscover() }
|
||||
},
|
||||
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
top = UiSpacing.Medium,
|
||||
bottom = UiSpacing.CardContent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = UiSpacing.PopupHorizontal),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
||||
) {
|
||||
TextButton(
|
||||
text = "取消",
|
||||
@@ -709,6 +713,7 @@ private fun PairingDialog(
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -816,9 +821,9 @@ fun FullscreenControlScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent)
|
||||
.padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium),
|
||||
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
|
||||
Text(
|
||||
@@ -877,6 +882,7 @@ private fun ScrcpyVideoSurface(
|
||||
) {
|
||||
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
|
||||
LaunchedEffect(session, currentSurface) {
|
||||
val surface = currentSurface
|
||||
@@ -889,7 +895,7 @@ private fun ScrcpyVideoSurface(
|
||||
onDispose {
|
||||
val surface = currentSurface
|
||||
if (surface != null) {
|
||||
scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
|
||||
taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -924,7 +930,7 @@ private fun ScrcpyVideoSurface(
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
val surface = currentSurface
|
||||
if (surface != null) {
|
||||
scope.launch {
|
||||
taskScope.launch {
|
||||
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
|
||||
}
|
||||
surface.release()
|
||||
@@ -944,48 +950,70 @@ private fun ScrcpyVideoSurface(
|
||||
@Composable
|
||||
internal fun DeviceTile(
|
||||
device: DeviceShortcut,
|
||||
actionText: String,
|
||||
isConnected: Boolean,
|
||||
actionEnabled: Boolean,
|
||||
actionInProgress: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onContentClick: () -> Unit,
|
||||
editing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onAction: () -> Unit,
|
||||
onEditorSave: (DeviceShortcut) -> Unit,
|
||||
onEditorDelete: () -> Unit,
|
||||
onEditorCancel: () -> Unit,
|
||||
) {
|
||||
val haptics = rememberAppHaptics()
|
||||
var name by rememberSaveable(device.id) { mutableStateOf(device.name) }
|
||||
var host by rememberSaveable(device.id) { mutableStateOf(device.host) }
|
||||
var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) }
|
||||
|
||||
LaunchedEffect(device) {
|
||||
name = device.name
|
||||
host = device.host
|
||||
port = device.port.toString()
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.defaultColors(
|
||||
color = if (device.online) MiuixTheme.colorScheme.surfaceContainer
|
||||
color =
|
||||
if (isConnected) MiuixTheme.colorScheme.surfaceContainer
|
||||
else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
|
||||
),
|
||||
pressFeedbackType = PressFeedbackType.Sink,
|
||||
pressFeedbackType = if (!editing) PressFeedbackType.Sink else PressFeedbackType.None,
|
||||
onClick = haptics.contextClick,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onContentClick,
|
||||
onLongClick = onLongPress,
|
||||
.then(
|
||||
if (!isConnected)
|
||||
Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
else Modifier
|
||||
)
|
||||
.padding(UiSpacing.PageItem),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = UiSpacing.CardContent),
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// statu dot
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(
|
||||
color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline,
|
||||
color =
|
||||
if (isConnected) Color(0xFF44C74F)
|
||||
else MiuixTheme.colorScheme.outline,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(UiSpacing.PageItem))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// device name/address
|
||||
Column {
|
||||
Text(
|
||||
device.name.ifBlank { device.host },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -1003,20 +1031,121 @@ internal fun DeviceTile(
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (actionInProgress) {
|
||||
CircularProgressIndicator(progress = null)
|
||||
Spacer(Modifier.width(UiSpacing.Medium))
|
||||
}
|
||||
TextButton(
|
||||
text = actionText,
|
||||
text = if (!isConnected) "连接" else "断开",
|
||||
onClick = onAction,
|
||||
enabled = actionEnabled && !actionInProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(editing) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = UiSpacing.ContentVertical)
|
||||
.padding(bottom = UiSpacing.ContentVertical),
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)
|
||||
) {
|
||||
SuperTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = "设备名称",
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SuperTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = "IP 地址",
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SuperTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it.filter(Char::isDigit) },
|
||||
label = "端口",
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
TextButton(
|
||||
text = "取消",
|
||||
onClick = onEditorCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "删除",
|
||||
onClick = onEditorDelete,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "保存",
|
||||
onClick = {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isNotBlank()) {
|
||||
onEditorSave(
|
||||
DeviceShortcut(
|
||||
name = name.trim(),
|
||||
host = trimmedHost,
|
||||
port = port.toIntOrNull() ?: Defaults.ADB_PORT,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeviceTileList(
|
||||
devices: List<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,30 +1158,25 @@ internal fun QuickConnectCard(
|
||||
onAddDevice: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
|
||||
pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None,
|
||||
pressFeedbackType =
|
||||
if (enabled) PressFeedbackType.Tilt
|
||||
else PressFeedbackType.None,
|
||||
insideMargin = PaddingValues(UiSpacing.Content),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = UiSpacing.Large,
|
||||
end = UiSpacing.Large,
|
||||
top = UiSpacing.PageItem,
|
||||
bottom = UiSpacing.Small,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = UiSpacing.Small),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.AddLink,
|
||||
contentDescription = "快速连接",
|
||||
tint = MiuixTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(UiSpacing.Medium))
|
||||
Text(
|
||||
"快速连接",
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -1066,20 +1190,14 @@ internal fun QuickConnectCard(
|
||||
enabled = enabled,
|
||||
useLabelAsPlaceholder = true,
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = UiSpacing.CardContent)
|
||||
.padding(bottom = UiSpacing.SectionTitleLeadingGap),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
||||
) {
|
||||
TextButton(
|
||||
text = "添加设备",
|
||||
@@ -1097,96 +1215,4 @@ internal fun QuickConnectCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(UiSpacing.CardContent),
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||
) {
|
||||
TextButton(
|
||||
text = "返回",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "删除",
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "保存",
|
||||
onClick = {
|
||||
val p = port.toIntOrNull() ?: Defaults.ADB_PORT
|
||||
val h = host.trim()
|
||||
if (h.isNotBlank()) {
|
||||
onSave(
|
||||
DeviceShortcut(
|
||||
name = name.trim(),
|
||||
host = h,
|
||||
port = p,
|
||||
online = device.online,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,44 +78,40 @@ class ReorderableList(
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||
) {
|
||||
if (item.icon != null) {
|
||||
Icon(
|
||||
item.icon,
|
||||
if (item.icon != null) Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.title
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = item.title,
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (item.subtitle.isNotBlank()) {
|
||||
Text(
|
||||
if (item.subtitle.isNotBlank()) Text(
|
||||
text = item.subtitle,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||
) {
|
||||
if (showCheckbox) {
|
||||
Checkbox(
|
||||
if (showCheckbox) Checkbox(
|
||||
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
|
||||
onClick = {
|
||||
onCheckboxChange?.invoke(item.id, !item.checked)
|
||||
},
|
||||
enabled = item.checkboxEnabled
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {},
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
@@ -193,7 +189,7 @@ class ReorderableList(
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.padding(UiSpacing.CardContent))
|
||||
Spacer(Modifier.padding(UiSpacing.ContentVertical))
|
||||
Text(
|
||||
text = item.title,
|
||||
color = MiuixTheme.colorScheme.onSurface,
|
||||
|
||||
@@ -302,7 +302,7 @@ class VirtualButtonBar(
|
||||
action.icon,
|
||||
contentDescription = action.title,
|
||||
modifier = Modifier
|
||||
.padding(end = UiSpacing.CardContent),
|
||||
.padding(end = UiSpacing.ContentVertical),
|
||||
)
|
||||
},
|
||||
title = action.title,
|
||||
|
||||
Reference in New Issue
Block a user