improve: implement more scrcpy options

This commit is contained in:
Miuzarte
2026-04-11 23:38:27 +08:00
parent 2639da4b2c
commit 30e4c0679b
10 changed files with 721 additions and 179 deletions

View File

@@ -59,6 +59,7 @@ fun Preset<Int>.indexOfOrNearest(raw: String): Int {
object ScrcpyPresets {
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px
val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps
val ScreenOffTimeout = Preset(listOf(0, 15, 30, 60, 120, 300, 600)) // sec
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
}

View File

@@ -133,6 +133,41 @@ object NativeAdbService {
response
}
suspend fun startApp(
packageName: String,
displayId: Int? = null,
forceStop: Boolean = false,
): String = mutex.withLock {
val normalizedPackageName = packageName.trim()
require(normalizedPackageName.isNotBlank()) { "package name is blank" }
if (forceStop) {
requireConnection().shell(
"am force-stop ${quoteShellArg(normalizedPackageName)}"
)
}
val resolveOutput = requireConnection().shell(
"cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}"
)
val componentName = resolveOutput
.lineSequence()
.map(String::trim)
.lastOrNull { '/' in it }
?: throw IllegalStateException(
"Cannot resolve launch activity for $normalizedPackageName"
)
val displayArg = displayId
?.takeIf { it >= 0 }
?.let { " --display $it" }
.orEmpty()
val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}"
val response = requireConnection().shell(command)
Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName")
response
}
suspend fun openShellStream(command: String): AdbSocketStream = mutex.withLock {
requireConnection().openStream("shell:$command")
}
@@ -161,5 +196,9 @@ object NativeAdbService {
?: throw IllegalStateException("ADB not connected")
}
private fun quoteShellArg(value: String): String {
return "'" + value.replace("'", "'\\''") + "'"
}
private const val TAG = "NativeAdbService"
}

View File

@@ -36,6 +36,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
@@ -89,6 +90,13 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
private const val PREVIEW_CARD_ITEM_KEY = "preview_card"
private const val PREVIEW_CARD_ITEM_INDEX = 3
private data class StartAppRequest(
val packageName: String,
val displayId: Int?,
val forceStop: Boolean,
val matchedAppLabel: String? = null,
)
@Composable
fun DeviceTabScreen(
scrollBehavior: ScrollBehavior,
@@ -517,11 +525,102 @@ fun DeviceTabPage(
}
}
suspend fun resolveStartAppRequest(
scrcpy: Scrcpy,
options: io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions,
): StartAppRequest? {
val raw = options.startApp.trim()
if (raw.isBlank()) {
return null
}
var query = raw
val forceStop = query.startsWith("+")
if (forceStop) {
query = query.drop(1).trimStart()
}
require(query.isNotBlank()) { "应用名或包名不能为空" }
val displayId = when {
options.videoSource != VideoSource.DISPLAY -> null
options.displayId >= 0 -> options.displayId
else -> null
}
if (!query.startsWith("?")) {
return StartAppRequest(
packageName = query,
displayId = displayId,
forceStop = forceStop,
)
}
val searchName = query.drop(1).trim()
require(searchName.isNotBlank()) { "应用名称不能为空" }
val apps = scrcpy.listings.getApps(forceRefresh = false)
val matches = apps.filter { it.label.startsWith(searchName, ignoreCase = true) }
require(matches.isNotEmpty()) { "未找到应用名以 \"$searchName\" 开头的应用" }
require(matches.size == 1) {
"按名称匹配到多个应用: " +
matches.take(5).joinToString { "${it.label} (${it.packageName})" }
}
return StartAppRequest(
packageName = matches[0].packageName,
displayId = displayId,
forceStop = forceStop,
matchedAppLabel = matches[0].label,
)
}
suspend fun startScrcpySession(openFullscreen: Boolean = false) {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options)
pendingScrollToPreview = true
if (openFullscreen) withContext(Dispatchers.Main) {
val startAppRequest = runCatching {
resolveStartAppRequest(scrcpy, options)
}.getOrElse { error ->
logEvent(
"启动应用请求无效: ${
error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
}",
Log.WARN,
error,
)
null
}
startAppRequest?.let { request ->
if (options.newDisplay.isNotBlank() && request.displayId == null) {
logEvent(
"当前实现无法获取 new display 的真实 displayId应用会启动到默认显示",
Log.WARN,
)
}
runCatching {
NativeAdbService.startApp(
packageName = request.packageName,
displayId = request.displayId,
forceStop = request.forceStop,
)
}.onSuccess {
val appLabelPart = request.matchedAppLabel?.let { " ($it)" }.orEmpty()
logEvent(
"已启动应用: ${request.packageName}$appLabelPart" +
request.displayId?.let { " @display=$it" }.orEmpty()
)
}.onFailure { error ->
logEvent(
"启动应用失败: ${request.packageName} (${
error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
})",
Log.WARN,
error,
)
}
}
if (options.fullscreen || openFullscreen) withContext(Dispatchers.Main) {
context.startActivity(StreamActivity.createIntent(context))
}
if (options.disableScreensaver)
@@ -549,6 +648,15 @@ fun DeviceTabPage(
snackbar.show("scrcpy 已启动")
}
suspend fun stopScrcpySession() {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
scrcpy.stop()
if (options.killAdbOnClose) {
// TODO
disconnectAdbConnection()
}
}
LaunchedEffect(pendingScrollToPreview, isPreviewCardVisible) {
if (!pendingScrollToPreview) return@LaunchedEffect
if (isPreviewCardVisible) return@LaunchedEffect

View File

@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -40,8 +41,13 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
@@ -61,6 +67,9 @@ import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.icon.extended.Tune
import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
@@ -153,26 +162,50 @@ internal fun ScrcpyAllOptionsPage(
.coerceAtLeast(0)
}
val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } }
val videoSourceItems = rememberSaveable { VideoSource.entries.map { it.string } }
val videoSourceIndex = rememberSaveable(soBundle.videoSource) {
Shared.VideoSource.entries
VideoSource.entries
.indexOfFirst { it.string == soBundle.videoSource }
.coerceAtLeast(0)
}
var displayIdInput by rememberSaveable(soBundle.displayId) {
mutableStateOf(
if (soBundle.displayId == -1) ""
else soBundle.displayId.toString()
)
val displays = scrcpy.listings.displays
val displayDropdownItems = rememberSaveable(displays, listRefreshVersion) {
listOf("默认") + displays.map { "${it.id} (${it.width}x${it.height})" }
}
val displayDropdownIndex = rememberSaveable(
soBundle.displayId,
displays,
listRefreshVersion,
) {
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
}
var cameraIdInput by rememberSaveable(soBundle.cameraId) {
mutableStateOf(soBundle.cameraId)
val cameras = scrcpy.listings.cameras
val cameraDropdownItems = rememberSaveable(cameras, listRefreshVersion) {
listOf("默认") + cameras.map { info ->
buildString {
append(info.id)
append(" (")
append(info.facing.string)
if (info.activeSize.isNotBlank()) {
append(", ")
append(info.activeSize)
}
append(')')
}
}
}
val cameraDropdownIndex = rememberSaveable(
soBundle.cameraId,
cameras,
listRefreshVersion,
) {
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
}
val cameraFacingItems = rememberSaveable {
listOf("默认") + Shared.CameraFacing.entries
listOf("默认") + CameraFacing.entries
.drop(1)
.map { it.string }
}
@@ -180,7 +213,7 @@ internal fun ScrcpyAllOptionsPage(
if (soBundle.cameraFacing.isEmpty()) {
0
} else {
val idx = Shared.CameraFacing.entries
val idx = CameraFacing.entries
.indexOfFirst { it.string == soBundle.cameraFacing }
if (idx > 0) idx else 0
}
@@ -192,24 +225,23 @@ internal fun ScrcpyAllOptionsPage(
val cameraSizes = scrcpy.listings.cameraSizes
val cameraSizeDropdownItems = rememberSaveable(cameraSizes, listRefreshVersion) {
listOf("自动", "自定义") + cameraSizes
listOf("默认", "自定义") + cameraSizes
}
var cameraSizeDropdownIndex by rememberSaveable(
val cameraSizeDropdownIndex = rememberSaveable(
soBundle.cameraSize,
soBundle.cameraSizeCustom,
soBundle.cameraSizeUseCustom,
cameraSizes,
listRefreshVersion,
) {
mutableIntStateOf(
when {
soBundle.cameraSizeUseCustom -> 1 // "自定义"
soBundle.cameraSize.isEmpty() -> 0 // "自动"
soBundle.cameraSize in cameraSizes ->
cameraSizes.indexOf(soBundle.cameraSize) + 2
when {
soBundle.cameraSizeUseCustom -> 1
soBundle.cameraSize.isEmpty() -> 0
soBundle.cameraSize in cameraSizes ->
cameraSizes.indexOf(soBundle.cameraSize) + 2
else -> 0 // 默认自动
}
)
else -> 0
}
}
var cameraArInput by rememberSaveable(soBundle.cameraAr) {
@@ -220,28 +252,19 @@ internal fun ScrcpyAllOptionsPage(
ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps)
}
val audioSourceItems = rememberSaveable {
Shared.AudioSource.entries.map { it.string }
}
val audioSourceIndex = rememberSaveable(soBundle.audioSource) {
Shared.AudioSource.entries
.indexOfFirst { it.string == soBundle.audioSource }
.coerceAtLeast(0)
val screenOffTimeoutPresetIndex = rememberSaveable(soBundle.screenOffTimeout) {
ScrcpyPresets.ScreenOffTimeout.indexOfOrNearest(
Tick(soBundle.screenOffTimeout).sec.toInt().coerceAtLeast(0)
)
}
val videoEncoderInfos = scrcpy.listings.videoEncoders
val audioEncoderInfos = scrcpy.listings.audioEncoders
val videoEncoders = remember(videoEncoderInfos) {
videoEncoderInfos.map { it.id }
val audioSourceItems = rememberSaveable {
AudioSource.entries.map { it.string }
}
val audioEncoders = remember(audioEncoderInfos) {
audioEncoderInfos.map { it.id }
}
val videoEncoderTypes = remember(videoEncoderInfos) {
videoEncoderInfos.associate { it.id to it.type.s }
}
val audioEncoderTypes = remember(audioEncoderInfos) {
audioEncoderInfos.associate { it.id to it.type.s }
val audioSourceIndex = rememberSaveable(soBundle.audioSource) {
AudioSource.entries
.indexOfFirst { it.string == soBundle.audioSource }
.coerceAtLeast(0)
}
val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) {
@@ -260,46 +283,118 @@ internal fun ScrcpyAllOptionsPage(
mutableStateOf(soBundle.audioCodecOptions)
}
val videoEncoderDropdownItems = rememberSaveable(videoEncoders, listRefreshVersion) {
listOf("") + videoEncoders
}
val videoEncoderIndex =
rememberSaveable(soBundle.videoEncoder, videoEncoders, listRefreshVersion) {
(videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0)
val videoEncoders = scrcpy.listings.videoEncoders
val videoEncoderItems by remember(videoEncoders, listRefreshVersion) {
derivedStateOf {
buildList {
add(SpinnerEntry(title = "自动"))
videoEncoders.forEach { info ->
add(
SpinnerEntry(
title = info.id,
summary = info.type.s,
)
)
}
}
}
}
val videoEncoderIndex = rememberSaveable(
soBundle.videoEncoder,
videoEncoders,
listRefreshVersion,
) {
(videoEncoders.indexOfFirst { it.id == soBundle.videoEncoder } + 1)
.coerceAtLeast(0)
}
val audioEncoderDropdownItems = rememberSaveable(audioEncoders, listRefreshVersion) {
listOf("") + audioEncoders
val audioEncoders = scrcpy.listings.audioEncoders
val audioEncoderItems by remember(audioEncoders, listRefreshVersion) {
derivedStateOf {
buildList {
add(SpinnerEntry(title = "自动"))
audioEncoders.forEach { info ->
add(
SpinnerEntry(
title = info.id,
summary = info.type.s,
)
)
}
}
}
}
val audioEncoderIndex = rememberSaveable(
soBundle.audioEncoder,
audioEncoders,
listRefreshVersion
listRefreshVersion,
) {
(audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0)
(audioEncoders.indexOfFirst { it.id == soBundle.audioEncoder } + 1)
.coerceAtLeast(0)
}
val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName ->
if (encoderName == "") {
SpinnerEntry(title = "自动")
val displayImePolicyItems = rememberSaveable {
listOf("默认") + DisplayImePolicy.entries
.drop(1)
.map { it.string }
}
val displayImePolicyIndex = rememberSaveable(soBundle.displayImePolicy) {
if (soBundle.displayImePolicy.isEmpty()) {
0
} else {
SpinnerEntry(
title = encoderName,
summary = videoEncoderTypes[encoderName],
)
val idx = DisplayImePolicy.entries
.indexOfFirst { it.string == soBundle.displayImePolicy }
if (idx > 0) idx else 0
}
}
val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName ->
if (encoderName == "") {
SpinnerEntry(title = "自动")
} else {
SpinnerEntry(
title = encoderName,
summary = audioEncoderTypes[encoderName],
)
val apps = remember(scrcpy.listings.apps, listRefreshVersion) {
scrcpy.listings.apps.sortedBy { it.packageName }
}
val appDropdownItems by remember(apps, listRefreshVersion) {
derivedStateOf {
buildList {
add(SpinnerEntry(title = ""))
add(SpinnerEntry(title = "自定义"))
apps.forEach { app ->
add(
SpinnerEntry(
icon = {
Icon(
imageVector =
if (app.system) MiuixIcons.Tune
else MiuixIcons.Store,
contentDescription = app.label,
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
)
},
title = app.label,
summary = app.packageName,
)
)
}
}
}
}
val appDropdownIndex = rememberSaveable(
soBundle.startApp,
soBundle.startAppCustom,
soBundle.startAppUseCustom,
apps,
listRefreshVersion,
) {
when {
soBundle.startAppUseCustom -> 1
soBundle.startApp.isEmpty() -> 0
apps.any { it.packageName == soBundle.startApp } ->
apps.indexOfFirst { it.packageName == soBundle.startApp } + 2
else -> 0
}
}
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
mutableStateOf(soBundle.startAppCustom)
}
// [<width>x<height>][/<dpi>]
val (ndWidth, ndHeight, ndDpi) = remember(soBundle.newDisplay) {
@@ -332,6 +427,13 @@ internal fun ScrcpyAllOptionsPage(
mutableStateOf(cY?.toString() ?: "")
}
val logLevelItems = rememberSaveable { LogLevel.entries.map { it.string } }
val logLevelIndex = rememberSaveable(soBundle.logLevel) {
LogLevel.entries
.indexOfFirst { it.string == soBundle.logLevel }
.coerceAtLeast(0)
}
var serverParamsPreview by rememberSaveable { mutableStateOf("") }
// 监听选项变化, 自动更新预览
LaunchedEffect(soBundle) {
@@ -351,7 +453,7 @@ internal fun ScrcpyAllOptionsPage(
serverParamsPreview = clientOptions
.toServerParams(0u)
.toList(simplify = true)
.toList(preview = true)
// improve readability using hard line breaks
.joinToString("\n")
}
@@ -374,19 +476,19 @@ internal fun ScrcpyAllOptionsPage(
item {
Card {
SwitchPreference(
title = "启动后关闭屏幕",
title = "scrcpy 启动后熄灭设备屏幕",
summary = "--turn-screen-off",
checked = soBundle.turnScreenOff,
onCheckedChange = {
soBundle = soBundle.copy(
turnScreenOff = it
)
if (it) {
if (it) snackbar.show(
// github.com/Genymobile/scrcpy/issues/3376
// github.com/Genymobile/scrcpy/issues/4587
// github.com/Genymobile/scrcpy/issues/5676
snackbar.show("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
}
"注意:大部分设备在关闭屏幕后刷新率会降低/减半"
)
},
)
SwitchPreference(
@@ -424,38 +526,45 @@ internal fun ScrcpyAllOptionsPage(
// enabled = control || video,
)
SuperSlider(
title = "投屏过程中受控机的息屏时间",
title = "scrcpy 启动后受控机的息屏时间",
summary = "--screen-off-timeout",
value = soBundle.screenOffTimeout.coerceAtLeast(0).toFloat(),
onValueChange = {
value = screenOffTimeoutPresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.ScreenOffTimeout.lastIndex)
soBundle = soBundle.copy(
screenOffTimeout = it.roundToInt()
.takeIf { value -> value > 0 }
?.toLong() ?: -1
screenOffTimeout = ScrcpyPresets.ScreenOffTimeout[idx]
.takeIf { it > 0 }
?.toLong()
?.let(Tick::fromSec)
?.value
?: -1
)
},
valueRange = 0f..600f,
steps = 600 - 1,
valueRange = 0f..ScrcpyPresets.ScreenOffTimeout.lastIndex.toFloat(),
steps = (ScrcpyPresets.ScreenOffTimeout.size - 2).coerceAtLeast(0),
unit = "s",
zeroStateText = "默认",
displayText =
if (soBundle.screenOffTimeout <= 0) "默认"
else soBundle.screenOffTimeout.toString(),
zeroStateText = "不修改",
showKeyPoints = true,
keyPoints = ScrcpyPresets.ScreenOffTimeout.indices.map { it.toFloat() },
displayText = Tick(soBundle.screenOffTimeout).sec.toString(),
inputInitialValue =
if (soBundle.screenOffTimeout <= 0) ""
else soBundle.screenOffTimeout.toString(),
else Tick(soBundle.screenOffTimeout).sec.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..86400f,
onInputConfirm = {
soBundle = soBundle.copy(
screenOffTimeout = it.toLongOrNull()
?.takeIf { value -> value > 0 }
?.let(Tick::fromSec)
?.value
?: -1
)
},
)
SwitchPreference(
title = "开始投屏时不唤醒屏幕",
title = "scrcpy 启动时不唤醒屏幕",
summary = "--no-power-on",
checked = !soBundle.powerOn,
onCheckedChange = {
@@ -465,7 +574,7 @@ internal fun ScrcpyAllOptionsPage(
},
)
SwitchPreference(
title = "结束投屏时息屏",
title = "scrcpy 结束后息屏",
summary = "--power-off-on-close",
checked = soBundle.powerOffOnClose,
onCheckedChange = {
@@ -474,6 +583,16 @@ internal fun ScrcpyAllOptionsPage(
)
},
)
SwitchPreference(
title = "scrcpy 启动后保持设备唤醒状态(插入电源)",
summary = "--stay-awake",
checked = soBundle.stayAwake,
onCheckedChange = {
soBundle = soBundle.copy(
stayAwake = it
)
},
)
SwitchPreference(
title = "显示物理触控",
summary = "--show-touches",
@@ -485,15 +604,42 @@ internal fun ScrcpyAllOptionsPage(
},
)
SwitchPreference(
title = "投屏时保持本机屏幕唤醒",
title = "启动后进入全屏",
summary = "--fullscreen",
checked = soBundle.fullscreen,
onCheckedChange = {
soBundle = soBundle.copy(
fullscreen = it
)
},
)
SwitchPreference(
title = "scrcpy 启动后保持本机屏幕唤醒",
summary = "--disable-screensaver",
checked = soBundle.disableScreensaver,
onCheckedChange = {
soBundle = soBundle.copy(
disableScreensaver = it
)
if (it) snackbar.show(
"不保证可用"
)
},
)
SwitchPreference(
title = "scrcpy 结束时断开 adb",
summary = "--kill-adb-on-close",
checked = soBundle.killAdbOnClose,
onCheckedChange = {
soBundle = soBundle.copy(
killAdbOnClose = it
)
if (it) snackbar.show(
"未实现"
)
},
enabled = false,
)
}
}
@@ -610,26 +756,46 @@ internal fun ScrcpyAllOptionsPage(
selectedIndex = videoSourceIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
videoSource = Shared.VideoSource.entries[it].string
videoSource = VideoSource.entries[it].string
)
},
)
AnimatedVisibility(soBundle.videoSource == "display") {
Column {
SuperTextField(
value = displayIdInput,
onValueChange = { displayIdInput = it },
onFocusLost = {
ArrowPreference(
title = "获取 Displays",
summary = "--list-displays",
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getDisplays(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
)
OverlayDropdownPreference(
title = "监视器 ID",
summary = "--display-id",
items = displayDropdownItems,
selectedIndex = displayDropdownIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
displayId = displayIdInput.toIntOrNull() ?: -1
displayId =
if (it == 0) -1
else displays[it - 1].id
)
},
label = "--display-id",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(all = UiSpacing.Large),
)
SuperSlider(
title = "最大分辨率",
@@ -694,19 +860,40 @@ internal fun ScrcpyAllOptionsPage(
}
AnimatedVisibility(soBundle.videoSource == "camera") {
Column {
SuperTextField(
value = cameraIdInput,
onValueChange = { cameraIdInput = it },
onFocusLost = {
ArrowPreference(
title = "获取 Cameras",
summary = "--list-cameras",
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getCameras(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
)
OverlayDropdownPreference(
title = "摄像头 ID",
summary = "--camera-id",
items = cameraDropdownItems,
selectedIndex = cameraDropdownIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
cameraId = cameraIdInput
cameraId =
if (it == 0) ""
else cameras[it - 1].id
)
},
label = "--camera-id",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(all = UiSpacing.Large),
)
OverlayDropdownPreference(
title = "摄像头朝向",
@@ -717,7 +904,7 @@ internal fun ScrcpyAllOptionsPage(
soBundle = soBundle.copy(
cameraFacing =
if (it == 0) ""
else Shared.CameraFacing.entries[it].string
else CameraFacing.entries[it].string
)
},
)
@@ -749,7 +936,6 @@ internal fun ScrcpyAllOptionsPage(
items = cameraSizeDropdownItems,
selectedIndex = cameraSizeDropdownIndex,
onSelectedIndexChange = {
cameraSizeDropdownIndex = it
when (it) {
0 -> {
// "自动"
@@ -765,7 +951,7 @@ internal fun ScrcpyAllOptionsPage(
soBundle = soBundle.copy(
cameraSizeUseCustom = true
)
cameraSizeCustomInput = soBundle.cameraSize
cameraSizeCustomInput = ""
}
else -> {
@@ -785,15 +971,14 @@ internal fun ScrcpyAllOptionsPage(
value = cameraSizeCustomInput,
onValueChange = { cameraSizeCustomInput = it },
onFocusLost = {
if (cameraSizeCustomInput in cameraSizes) {
soBundle = if (cameraSizeCustomInput in cameraSizes) {
// 输入的值存在于列表中, 取消自定义输入
cameraSizeDropdownIndex = cameraSizes
.indexOf(cameraSizeCustomInput) + 2
soBundle = soBundle.copy(
soBundle.copy(
cameraSize = cameraSizeCustomInput,
cameraSizeUseCustom = false
)
} else {
soBundle = soBundle.copy(
soBundle.copy(
cameraSizeCustom = cameraSizeCustomInput
)
}
@@ -859,26 +1044,6 @@ internal fun ScrcpyAllOptionsPage(
)
},
)
SwitchPreference(
title = "关闭虚拟显示器时保留内容",
summary = "--no-vd-destroy-content",
checked = !soBundle.vdDestroyContent,
onCheckedChange = {
soBundle = soBundle.copy(
vdDestroyContent = !it
)
},
)
SwitchPreference(
title = "禁用虚拟显示器系统装饰",
summary = "--no-vd-system-decorations",
checked = !soBundle.vdSystemDecorations,
onCheckedChange = {
soBundle = soBundle.copy(
vdSystemDecorations = !it
)
},
)
}
}
@@ -894,7 +1059,7 @@ internal fun ScrcpyAllOptionsPage(
selectedIndex = audioSourceIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
audioSource = Shared.AudioSource.entries[it].string
audioSource = AudioSource.entries[it].string
)
},
)
@@ -943,7 +1108,7 @@ internal fun ScrcpyAllOptionsPage(
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getVideoEncoders(forceRefresh = true)
scrcpy.listings.getEncoders(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
@@ -959,11 +1124,13 @@ internal fun ScrcpyAllOptionsPage(
OverlaySpinnerPreference(
title = "视频编码器",
summary = "--video-encoder",
items = videoEncoderEntries,
items = videoEncoderItems,
selectedIndex = videoEncoderIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
videoEncoder = videoEncoderEntries[it].title ?: ""
videoEncoder =
if (it == 0) ""
else videoEncoders[it - 1].id
)
},
)
@@ -984,11 +1151,13 @@ internal fun ScrcpyAllOptionsPage(
OverlaySpinnerPreference(
title = "音频编码器",
summary = "--audio-encoder",
items = audioEncoderEntries,
items = audioEncoderItems,
selectedIndex = audioEncoderIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
audioEncoder = audioEncoderEntries[it].title ?: ""
audioEncoder =
if (it == 0) ""
else audioEncoders[it - 1].id
)
},
)
@@ -1009,6 +1178,160 @@ internal fun ScrcpyAllOptionsPage(
}
}
if (soBundle.videoSource == "display") item {
Card {
OverlayDropdownPreference(
title = "输入法显示策略",
summary = "--display-ime-policy",
items = displayImePolicyItems,
selectedIndex = displayImePolicyIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
displayImePolicy =
if (it == 0) ""
else DisplayImePolicy.entries[it].string
)
},
)
SwitchPreference(
title = "关闭虚拟显示器时保留内容",
summary = "--no-vd-destroy-content",
checked = !soBundle.vdDestroyContent,
onCheckedChange = {
soBundle = soBundle.copy(
vdDestroyContent = !it
)
},
)
SwitchPreference(
title = "禁用虚拟显示器系统装饰",
summary = "--no-vd-system-decorations",
checked = !soBundle.vdSystemDecorations,
onCheckedChange = {
soBundle = soBundle.copy(
vdSystemDecorations = !it
)
},
)
SwitchPreference(
title = "禁用结束后清理",
summary = "--no-downsize-on-error",
checked = !soBundle.downsizeOnError,
onCheckedChange = {
soBundle = soBundle.copy(
downsizeOnError = !it
)
if (it) snackbar.show(
"默认情况下,在 MediaCodec 出错时," +
"scrcpy 会自动尝试使用更低的分辨率重新开始。" +
"\n此选项将禁用此行为。"
)
},
)
SwitchPreference(
title = "禁用结束后清理",
summary = "--no-cleanup",
checked = !soBundle.cleanup,
onCheckedChange = {
soBundle = soBundle.copy(
cleanup = !it
)
if (it) snackbar.show(
"默认情况下scrcpy 会从设备中移除服务器二进制文件," +
"并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)" +
"\n此选项将禁用此清理操作"
)
},
)
}
}
if (soBundle.videoSource == "display") item {
Card {
ArrowPreference(
title = "获取应用列表",
summary = "--list-apps",
onClick = {
if (refreshBusy) return@ArrowPreference
scope.launch {
refreshBusy = true
snackbar.show("获取中")
try {
withContext(Dispatchers.IO) {
scrcpy.listings.getApps(forceRefresh = true)
}
listRefreshVersion += 1
snackbar.show("获取成功")
} catch (e: Exception) {
snackbar.show("刷新失败: ${e.message}")
} finally {
refreshBusy = false
}
}
},
)
OverlaySpinnerPreference(
title = "scrcpy 启动后打开应用",
summary = "--start-app\nTODO: 未实现虚拟屏配合",
items = appDropdownItems,
selectedIndex = appDropdownIndex,
onSelectedIndexChange = {
when (it) {
0 -> {
soBundle = soBundle.copy(
startApp = "",
startAppUseCustom = false,
)
startAppCustomInput = ""
}
1 -> {
soBundle = soBundle.copy(
startAppUseCustom = true
)
startAppCustomInput = ""
}
else -> {
val selectedApp = apps[it - 2]
soBundle = soBundle.copy(
startApp = selectedApp.packageName,
startAppUseCustom = false,
)
startAppCustomInput = ""
}
}
},
)
AnimatedVisibility(soBundle.startAppUseCustom) {
SuperTextField(
value = startAppCustomInput,
onValueChange = { startAppCustomInput = it },
onFocusLost = {
val matchedAppIndex = apps
.indexOfFirst { it.packageName == startAppCustomInput }
soBundle = if (matchedAppIndex >= 0) {
soBundle.copy(
startApp = apps[matchedAppIndex].packageName,
startAppUseCustom = false,
)
} else {
soBundle.copy(
startAppCustom = startAppCustomInput
)
}
},
label = "--start-app",
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(all = UiSpacing.Large),
)
}
}
}
if (soBundle.videoSource == "display") item {
Card {
Column(
@@ -1243,6 +1566,22 @@ internal fun ScrcpyAllOptionsPage(
}
}
item {
Card {
OverlayDropdownPreference(
title = "日志等级",
summary = "--verbosity",
items = logLevelItems,
selectedIndex = logLevelIndex,
onSelectedIndexChange = {
soBundle = soBundle.copy(
logLevel = LogLevel.entries[it].string
)
},
)
}
}
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
}
}

View File

@@ -164,7 +164,7 @@ data class ClientOptions(
// var clipboardAutosync: Boolean = true, // to server
// --no-downsize-on-error
// var downsizeOnError: Boolean = true, // to server
var downsizeOnError: Boolean = true, // to server
// var tcpip: Boolean, // to server
// var tcpipDst: String = "", // to server
@@ -172,7 +172,7 @@ data class ClientOptions(
// var selectTcpip: Boolean, // to server
// --no-cleanup
var cleanUp: Boolean = true, // to server
var cleanup: Boolean = true, // to server
// var startFpsCounter: Boolean,
@@ -226,6 +226,12 @@ data class ClientOptions(
M4A, MKA, OPUS, AAC, FLAC, WAV -> true
else -> false
}
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: AUTO
}
}
fun fix(): ClientOptions {
@@ -552,7 +558,9 @@ data class ClientOptions(
powerOffOnClose = powerOffOnClose,
cleanUp = cleanUp,
downsizeOnError = downsizeOnError,
cleanUp = cleanup,
powerOn = powerOn,
// killAdbOnClose == killAdbOnClose, // client side

View File

@@ -344,7 +344,7 @@ class Scrcpy(
return getEncoders(forceRefresh).second
}
private suspend fun getEncoders(forceRefresh: Boolean = false)
suspend fun getEncoders(forceRefresh: Boolean = false)
: Pair<List<EncoderInfo>, List<EncoderInfo>> {
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
return cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
@@ -455,7 +455,7 @@ class Scrcpy(
video = false,
audio = false,
control = false,
cleanUp = false,
cleanup = false,
list = list,
)
val serverParams = options.toServerParams(scid)
@@ -1065,6 +1065,7 @@ class Scrcpy(
}
serverLogBuffer.addLast(line)
}
logEvent(line)
Log.i(TAG, "[server:$socketName] $line")
}
}

View File

@@ -73,7 +73,7 @@ data class ServerParams(
var powerOffOnClose: Boolean,
// var downsizeOnError: Boolean,
var downsizeOnError: Boolean,
// var tcpip: Boolean,
// var tcpipDst: String,
// var selectUsb: Boolean,
@@ -107,13 +107,12 @@ data class ServerParams(
return (extraArgs.toList() + this.toList()).joinToString(SEPARATOR)
}
fun toList(simplify: Boolean = false): MutableList<String> {
fun toList(preview: Boolean = false): MutableList<String> {
val cmd = mutableListOf<String>()
if (!simplify) {
cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
}
if (!preview) cmd.add("scid=${scid.toString(16)}")
cmd.add("log_level=${logLevel.string}")
if (!video) {
cmd.add("video=false")
@@ -216,7 +215,7 @@ data class ServerParams(
require(screenOffTimeout >= 0) {
"screen_off_timeout must be >= 0"
}
cmd.add("screen_off_timeout=${screenOffTimeout.toMs()}")
cmd.add("screen_off_timeout=${screenOffTimeout.ms}")
}
if (videoCodecOptions.isNotBlank()) {
validate(videoCodecOptions)
@@ -242,8 +241,6 @@ data class ServerParams(
if (!clipBoardAutosync) {
cmd.add("clipboard_autosync=false")
}
// not implemented
val downsizeOnError = false
if (!downsizeOnError) {
cmd.add("downsize_on_error=false")
}
@@ -285,4 +282,4 @@ data class ServerParams(
}
return cmd
}
}
}

View File

@@ -15,6 +15,11 @@ class Shared {
fun toMs(): Long = value / 1000
fun toSec(): Long = value / 1_000_000
fun toSecDouble(): Double = value / 1_000_000.0
val ns: Long get() = toNs()
val us: Long get() = toUs()
val ms: Long get() = toMs()
val sec: Long get() = toSec()
val secDouble: Double get() = toSecDouble()
operator fun plus(other: Tick): Tick = Tick(value + other.value)
operator fun minus(other: Tick): Tick = Tick(value - other.value)
@@ -38,17 +43,23 @@ class Shared {
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
}
enum class ListOptions(val value: Int) {
NULL(0x0),
ENCODERS(0x1), // --list-encoders
DISPLAYS(0x2), // --list-displays
CAMERAS(0x4), // --list-cameras
CAMERA_SIZES(0x8), // --list-camera-sizes
APPS(0x10); // --list-apps
enum class ListOptions(val string: String, val value: Int) {
NULL("null", 0x0),
ENCODERS("encoders", 0x1), // --list-encoders
DISPLAYS("displays", 0x2), // --list-displays
CAMERAS("cameras", 0x4), // --list-cameras
CAMERA_SIZES("camera_sizes", 0x8), // --list-camera-sizes
APPS("apps", 0x10); // --list-apps
infix fun or(other: ListOptions) = value or other.value
infix fun and(other: ListOptions) = value and other.value
infix fun has(other: ListOptions) = this and other != 0
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: NULL
}
}
enum class EncoderType(val s: String) {
@@ -62,6 +73,12 @@ class Shared {
INFO("info"),
WARN("warn"),
ERROR("error");
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: VERBOSE
}
}
enum class Codec(
@@ -177,6 +194,12 @@ class Shared {
UNLOCKED("unlocked"), // ignore
LOCKED_VALUE("locked_value"), // "@${orientation.string}"
LOCKED_INITIAL("locked_initial"); // "@"
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: UNLOCKED
}
}
enum class DisplayImePolicy(val string: String) {
@@ -184,5 +207,11 @@ class Shared {
LOCAL("local"),
FALLBACK("fallback"),
HIDE("hide");
companion object {
fun fromString(value: String) =
entries.find { it.string.equals(value, ignoreCase = true) }
?: UNDEFINED
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions.RecordFormat
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
@@ -74,11 +75,11 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
)
val LOG_LEVEL = Pair(
stringPreferencesKey("log_level"),
"info",
LogLevel.INFO.string,
)
val VIDEO_CODEC = Pair(
stringPreferencesKey("video_codec"),
"h264",
Codec.H264.string,
)
val AUDIO_CODEC = Pair(
stringPreferencesKey("audio_codec"),
@@ -138,7 +139,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
)
val DISPLAY_IME_POLICY = Pair(
stringPreferencesKey("display_ime_policy"),
"undefined",
DisplayImePolicy.UNDEFINED.string,
)
val DISPLAY_ID = Pair(
intPreferencesKey("display_id"),
@@ -184,6 +185,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("power_off_on_close"),
false,
)
val DOWNSIZE_ON_ERROR = Pair(
booleanPreferencesKey("downsize_on_error"),
true,
)
val CLEANUP = Pair(
booleanPreferencesKey("cleanup"),
true,
@@ -228,6 +233,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stringPreferencesKey("start_app"),
"",
)
val START_APP_CUSTOM = Pair(
stringPreferencesKey("start_app_custom"),
"",
)
val START_APP_USE_CUSTOM = Pair(
booleanPreferencesKey("start_app_use_custom"),
false,
)
val VD_DESTROY_CONTENT = Pair(
booleanPreferencesKey("vd_destroy_content"),
true,
@@ -334,6 +347,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val stayAwake: Boolean,
val disableScreensaver: Boolean,
val powerOffOnClose: Boolean,
val downsizeOnError: Boolean,
val cleanup: Boolean,
val powerOn: Boolean,
val video: Boolean,
@@ -345,6 +359,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val audioDup: Boolean,
val newDisplay: String,
val startApp: String,
val startAppCustom: String,
val startAppUseCustom: Boolean,
val vdDestroyContent: Boolean,
val vdSystemDecorations: Boolean
) : Parcelable {
@@ -449,6 +465,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = preferences.read(STAY_AWAKE),
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON),
video = preferences.read(VIDEO),
@@ -460,6 +477,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
audioDup = preferences.read(AUDIO_DUP),
newDisplay = preferences.read(NEW_DISPLAY),
startApp = preferences.read(START_APP),
startAppCustom = preferences.read(START_APP_CUSTOM),
startAppUseCustom = preferences.read(START_APP_USE_CUSTOM),
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
)
@@ -493,12 +512,12 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
cameraAr = bundle.cameraAr,
cameraFps = bundle.cameraFps.toUShort(),
logLevel = LogLevel.valueOf(bundle.logLevel.uppercase()),
logLevel = LogLevel.fromString(bundle.logLevel),
videoCodec = Codec.fromString(bundle.videoCodec),
audioCodec = Codec.fromString(bundle.audioCodec),
videoSource = VideoSource.fromString(bundle.videoSource),
audioSource = AudioSource.fromString(bundle.audioSource),
recordFormat = ClientOptions.RecordFormat.valueOf(bundle.recordFormat.uppercase()),
recordFormat = RecordFormat.fromString(bundle.recordFormat),
cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
maxSize = bundle.maxSize.toUShort(),
videoBitRate = bundle.videoBitRate,
@@ -506,12 +525,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
maxFps = bundle.maxFps,
angle = bundle.angle,
captureOrientation = Orientation.fromInt(bundle.captureOrientation),
captureOrientationLock = OrientationLock.valueOf(
bundle.captureOrientationLock.uppercase()
),
captureOrientationLock = OrientationLock.fromString(bundle.captureOrientationLock),
displayOrientation = Orientation.fromInt(bundle.displayOrientation),
recordOrientation = Orientation.fromInt(bundle.recordOrientation),
displayImePolicy = DisplayImePolicy.valueOf(bundle.displayImePolicy.uppercase()),
displayImePolicy = DisplayImePolicy.fromString(bundle.displayImePolicy),
displayId = bundle.displayId,
screenOffTimeout = Tick(bundle.screenOffTimeout),
showTouches = bundle.showTouches,
@@ -523,17 +540,18 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = bundle.stayAwake,
disableScreensaver = bundle.disableScreensaver,
powerOffOnClose = bundle.powerOffOnClose,
cleanUp = bundle.cleanup,
downsizeOnError = bundle.downsizeOnError,
cleanup = bundle.cleanup,
powerOn = bundle.powerOn,
video = bundle.video,
audio = bundle.audio,
requireAudio = bundle.requireAudio,
killAdbOnClose = bundle.killAdbOnClose,
cameraHighSpeed = bundle.cameraHighSpeed,
list = ListOptions.valueOf(bundle.list.uppercase()),
list = ListOptions.fromString(bundle.list),
audioDup = bundle.audioDup,
newDisplay = bundle.newDisplay,
startApp = bundle.startApp,
startApp = if (!bundle.startAppUseCustom) bundle.startApp else bundle.startAppCustom,
vdDestroyContent = bundle.vdDestroyContent,
vdSystemDecorations = bundle.vdSystemDecorations
)