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