upstream: scrcpy v4 (#27)

* feat: download scrcpy-server in pre-build
* protocol alignment
* implement new options
* fix: switching after creating a new conf
* feat: implement `--new-display`
This commit is contained in:
謬紗特
2026-05-15 19:35:03 +08:00
committed by GitHub
parent 9e44788f84
commit a27d8638b4
18 changed files with 951 additions and 483 deletions

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ scrcpy.help*
*.mp4 *.mp4
abandoned_assets/ abandoned_assets/
# scrcpy-server binary (downloaded by Gradle during build)
app/src/main/assets/bin/scrcpy-server-v*

39
TODO.md
View File

@@ -4,45 +4,6 @@
- 新的原创图标而非从 scrcpy 的图标派生 - 新的原创图标而非从 scrcpy 的图标派生
## scrcpy v4.0
### 协议变更 (breaking)
- 会话包 (session packet):视频流新增 12 字节会话包 (替代旧的 codec meta 中包含的 width/height),携带帧宽高 + `client_resized` 标志位
- `Scrcpy.kt:1091-1095` 当前读视频 meta 12 字节 (codec+width+height),需改为只读 4 字节 codec id
- `Scrcpy.kt:1157-1166` 视频读取循环需新增分支bit 63=1 → 解析 session packet (width/height/flags)bit 63=0 → 媒体包
- `SessionInfo` 的 width/height 不再从 init meta 获得,需从首个 session packet 或第一帧捕获后填充
- 音频流无 session packet`attachAudioConsumer()` 不受影响
- 帧头 (frame header)PTS 从 `u62` 改为 `u61`,新增 `media_packet_flag` 位 (最高 bit = 1 表示会话包,= 0 表示媒体包)
- `Scrcpy.kt:1728` `PACKET_FLAG_CONFIG`: `1L shl 63``1L shl 62`
- `Scrcpy.kt:1729` `PACKET_FLAG_KEY_FRAME`: `1L shl 62``1L shl 61`
- `Scrcpy.kt:1730` `PACKET_PTS_MASK`: `(1L shl 62) - 1``(1L shl 61) - 1`
- 新增 `PACKET_FLAG_SESSION = 1L shl 63`
- 视频读取 `Scrcpy.kt:1168` 和音频读取 `Scrcpy.kt:1215` 的 flag 解析逻辑须同步更新
- 流元数据:`sendCodecMeta``sendStreamMeta``writeVideoHeader()` 不再发送 width/height (只发送 codec id 4 字节)
- `ServerParams.kt` 当前未传递此参数,无需改动 (v4.0 server 默认 `sendStreamMeta=true`)
- `Scrcpy.kt:1091-1095` 改为只读 `vInput.readInt()` (仅 codec id)
- 新增 4 个控制消息类型:
- `TYPE_CAMERA_SET_TORCH` (18) — 1 字节 bool
- `Scrcpy.kt:1739` 需新增常量 + `ControlWriter` 新增 `setCameraTorch(on: Boolean)` 方法
- `TYPE_CAMERA_ZOOM_IN` (19) / `TYPE_CAMERA_ZOOM_OUT` (20) — 无参数
- 同上,各一个无参方法
- `TYPE_RESIZE_DISPLAY` (21) — 2×uint16 (width, height)
- 同上,新增 `resizeDisplay(w: Int, h: Int)` 方法;配合 flex display 功能使用
- 版本号:`Scrcpy.kt:121` `DEFAULT_SERVER_VERSION` 需从 `"3.3.4"` 改为 `"4.0"`,否则 v4.0 server 拒绝连接
### 新参数
- (`-x` / `--flex-display`)Flex display 虚拟显示器大小连续跟随窗口缩放
- `--keep-active`:每 4s 模拟用户活动防止息屏
- `--camera-zoom`:镜头初始缩放值 (maybeTODO: 虚拟按键调整缩放)
- `--camera-torch`:手电筒初始启用
- `--min-size-alignment`:强制视频尺寸对齐倍数 (power-of-2)
## PARAMS ## PARAMS
- ~~orientation locking~~ - ~~orientation locking~~

View File

@@ -1,3 +1,5 @@
import java.net.URI
import java.security.MessageDigest
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -56,8 +58,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 30 versionCode = 31
versionName = "0.3.4" versionName = "0.4.0"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -162,3 +164,75 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
} }
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0"
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0"
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a"
val downloadScrcpyServer by tasks.registering {
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
group = "build setup"
inputs.property("downloadUrl", scrcpyServerDownloadUrl)
inputs.property("expectedSha256", scrcpyServerSha256)
outputs.file(scrcpyServerAssetFile)
doLast {
val file = outputs.files.singleFile
val url = inputs.properties["downloadUrl"] as String
val expectedSha = inputs.properties["expectedSha256"] as String
val dir = file.parentFile
if (!dir.exists()) dir.mkdirs()
fun computeSha256(f: File): String {
return f.inputStream().use { input ->
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(8192)
var count: Int
while (input.read(buffer).also { count = it } >= 0) {
digest.update(buffer, 0, count)
}
digest.digest().joinToString("") { "%02x".format(it) }
}
}
val needsDownload = !file.exists() || computeSha256(file) != expectedSha
if (needsDownload) {
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...")
try {
URI(url).toURL().openStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
} catch (e: Exception) {
throw GradleException(
"Failed to download scrcpy-server-v4.0 from GitHub releases.\n" +
" URL: $url\n" +
" You may download it manually and place it at: ${file.absolutePath}\n" +
" If you are behind a proxy, check your Gradle proxy settings\n" +
" (gradle.properties: systemProp.https.proxyHost / systemProp.https.proxyPort).",
e
)
}
val actualSha = computeSha256(file)
require(actualSha == expectedSha) {
"SHA256 mismatch for scrcpy-server-v4.0!\n" +
" Expected: $expectedSha\n" +
" Got: $actualSha\n" +
" Delete ${file.absolutePath} to retry download."
}
logger.lifecycle("scrcpy-server-v4.0 downloaded and verified.")
} else {
logger.lifecycle("scrcpy-server-v4.0 exists with correct SHA256, skip download.")
}
}
}
tasks.named("preBuild") {
dependsOn(downloadScrcpyServer)
}

View File

View File

@@ -8,6 +8,11 @@ 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 io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
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
@@ -22,6 +27,7 @@ import java.util.concurrent.CopyOnWriteArraySet
*/ */
object NativeCoreFacade { object NativeCoreFacade {
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val renderer = PersistentVideoRenderer() private val renderer = PersistentVideoRenderer()
private var activeSurfaceId: Int? = null private var activeSurfaceId: Int? = null
private var decoder: AnnexBDecoder? = null private var decoder: AnnexBDecoder? = null
@@ -41,6 +47,10 @@ object NativeCoreFacade {
@Volatile @Volatile
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
@Volatile
private var scrcpyRef: Scrcpy? = null
suspend fun close() { suspend fun close() {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
releaseAllDecoders() releaseAllDecoders()
@@ -78,6 +88,10 @@ object NativeCoreFacade {
return return
} }
} }
if (session.width <= 0 || session.height <= 0) {
Log.i(TAG, "attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})")
return
}
createOrReplaceDecoder(session) createOrReplaceDecoder(session)
} }
} }
@@ -157,8 +171,10 @@ object NativeCoreFacade {
*/ */
suspend fun onScrcpySessionStarted( suspend fun onScrcpySessionStarted(
session: Scrcpy.Session.SessionInfo, session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session sessionMgr: Scrcpy.Session,
scrcpy: Scrcpy,
) = sessionLifecycleMutex.withLock { ) = sessionLifecycleMutex.withLock {
scrcpyRef = scrcpy
currentSessionInfo = session currentSessionInfo = session
releaseAllDecoders() releaseAllDecoders()
synchronized(bootstrapLock) { synchronized(bootstrapLock) {
@@ -166,8 +182,13 @@ object NativeCoreFacade {
latestConfigPacket = null latestConfigPacket = null
} }
if (activeSurfaceId != null || recordingSurfaceAttached) { if (activeSurfaceId != null || recordingSurfaceAttached) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") // v4.0: width/height come from first video session packet, not from initial metadata
createOrReplaceDecoder(session) if (session.width > 0 && session.height > 0) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session)
} else {
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
}
} }
packetCount = 0 packetCount = 0
sessionMgr.attachVideoConsumer { packet -> sessionMgr.attachVideoConsumer { packet ->
@@ -179,9 +200,10 @@ object NativeCoreFacade {
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
) )
} }
val currentDecoder = decoder ?: return@attachVideoConsumer
val dec = decoder ?: return@attachVideoConsumer
runCatching { runCatching {
currentDecoder.feedAnnexB( dec.feedAnnexB(
packet.data, packet.data,
packet.ptsUs, packet.ptsUs,
packet.isKeyFrame, packet.isKeyFrame,
@@ -191,6 +213,34 @@ object NativeCoreFacade {
} }
} }
/**
* Called by Scrcpy when a video session packet arrives with new dimensions.
* Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
*/
fun onVideoSizeChanged(width: Int, height: Int) {
lifecycleScope.launch {
sessionLifecycleMutex.withLock {
val scrcpy = scrcpyRef ?: return@withLock
val info = scrcpy.currentSessionState.value ?: return@withLock
if (info.width <= 0 || info.height <= 0) return@withLock
if (decoder == null) {
// Initial creation (v4.0: deferred until first session packet)
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}")
currentSessionInfo = info
createOrReplaceDecoder(info)
} else if (currentSessionInfo != null &&
(info.width != currentSessionInfo!!.width || info.height != currentSessionInfo!!.height)
) {
// Flex display: rebuild decoder on size change
Log.i(TAG, "onVideoSizeChanged(): rebuild decoder ${currentSessionInfo!!.width}x${currentSessionInfo!!.height}${info.width}x${info.height}")
currentSessionInfo = info
createOrReplaceDecoder(info)
}
}
}
}
/** /**
* Called by Scrcpy.kt when a session stops. * Called by Scrcpy.kt when a session stops.
* Cleans up decoders and resets state. * Cleans up decoders and resets state.
@@ -201,6 +251,7 @@ object NativeCoreFacade {
bootstrapPackets.clear() bootstrapPackets.clear()
latestConfigPacket = null latestConfigPacket = null
} }
scrcpyRef = null
currentSessionInfo = null currentSessionInfo = null
recordingSurfaceAttached = false recordingSurfaceAttached = false
} }

View File

@@ -35,28 +35,8 @@ class Preset<T : Comparable<T>>(val values: List<T>) {
} }
} }
/**
* Extension function for Int presets to find index from Int value.
*/
fun Preset<Int>.indexOfOrNearest(raw: Int): Int {
val exact = values.indexOf(raw)
if (exact >= 0) return exact
val nearest = values.withIndex().minByOrNull { (_, preset) ->
kotlin.math.abs(preset - raw)
}
return nearest?.index ?: 0
}
/**
* Extension function for Int presets to find index from String value.
*/
fun Preset<Int>.indexOfOrNearest(raw: String): Int {
if (raw.isBlank()) return 0
val value = raw.toIntOrNull() ?: return 0
return indexOfOrNearest(value)
}
object ScrcpyPresets { object ScrcpyPresets {
val MinSizeAlignment = Preset(listOf(1, 2, 4, 8, 16)) // power-of-2
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 ScreenOffTimeout = Preset(listOf(0, 15, 30, 60, 120, 300, 600)) // sec val ScreenOffTimeout = Preset(listOf(0, 15, 30, 60, 120, 300, 600)) // sec

View File

@@ -605,9 +605,13 @@ internal class DeviceTabViewModel(
val videoDetail = val videoDetail =
if (!resolvedOptions.video) "off" if (!resolvedOptions.video) "off"
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" else {
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + val codec = session.codec?.string ?: "null"
"@%.1fMbps".format(activeBundle.videoBitRate / 1_000_000f) val sizeHint = if (session.width > 0 && session.height > 0) " ${session.width}x${session.height}" else ""
val bitrateSuffix = if (activeBundle.videoBitRate <= 0) " @default"
else " @%.1fMbps".format(activeBundle.videoBitRate / 1_000_000f)
"$codec$sizeHint$bitrateSuffix"
}
val audioDetail = val audioDetail =
if (!activeBundle.audio) "off" if (!activeBundle.audio) "off"

View File

@@ -59,11 +59,12 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
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.util.Debouncer
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
@@ -683,6 +684,20 @@ fun FullscreenControlPage(
) )
} }
val resizeDebouncer = remember {
Debouncer(300L) { w, h ->
coroutineScope.launch(Dispatchers.IO) {
scrcpy.resizeDisplay(w, h)
}
}
}
DisposableEffect(resizeDebouncer) {
onDispose {
resizeDebouncer.cancel()
}
}
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -694,7 +709,17 @@ fun FullscreenControlPage(
} }
else Modifier else Modifier
) )
.onSizeChanged { touchAreaSize = it }, .onSizeChanged { size ->
touchAreaSize = size
if (scrcpy.flexDisplay) {
val sessionKnown = scrcpy.currentSessionState.value?.let {
it.width > 0 && it.height > 0
} ?: false
if (sessionKnown) {
resizeDebouncer.invoke(size.width, size.height)
}
}
},
) { ) {
val sessionAspect = val sessionAspect =
if (session.height == 0) 16f / 9f if (session.height == 0) 16f / 9f

View File

@@ -331,6 +331,7 @@ internal fun ScrcpyAllOptionsScreen(
bundle = copySourceBundle, bundle = copySourceBundle,
) )
selectedProfileId = created.id selectedProfileId = created.id
bindCurrentConnectedDevice(created.id)
} }
ProfileDialogMode.Rename -> { ProfileDialogMode.Rename -> {
@@ -597,6 +598,9 @@ internal fun ScrcpyAllOptionsPage(
var cameraArInput by rememberSaveable(soBundle.cameraAr) { var cameraArInput by rememberSaveable(soBundle.cameraAr) {
mutableStateOf(soBundle.cameraAr) mutableStateOf(soBundle.cameraAr)
} }
var cameraZoomInput by rememberSaveable(soBundle.cameraZoom) {
mutableStateOf(soBundle.cameraZoom)
}
val cameraFpsPresetIndex = rememberSaveable(soBundle.cameraFps) { val cameraFpsPresetIndex = rememberSaveable(soBundle.cameraFps) {
ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps)
@@ -639,6 +643,10 @@ internal fun ScrcpyAllOptionsPage(
} }
} }
val minSizeAlignmentPresetIndex = rememberSaveable(soBundle.minSizeAlignment) {
ScrcpyPresets.MinSizeAlignment.indexOfOrNearest(soBundle.minSizeAlignment)
}
val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) { val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) {
ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize)
} }
@@ -1017,6 +1025,16 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.scrcpyopt_keep_active),
summary = "--keep-active",
checked = soBundle.keepActive,
onCheckedChange = {
soBundle = soBundle.copy(
keepActive = it
)
},
)
SwitchPreference( SwitchPreference(
title = stringResource(R.string.scrcpyopt_show_touches), title = stringResource(R.string.scrcpyopt_show_touches),
summary = "--show-touches", summary = "--show-touches",
@@ -1224,6 +1242,39 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SuperSlider(
title = stringResource(R.string.scrcpyopt_min_size_alignment),
summary = "--min-size-alignment",
value = minSizeAlignmentPresetIndex.toFloat(),
onValueChange = {
val idx = it.roundToInt()
.coerceIn(0, ScrcpyPresets.MinSizeAlignment.lastIndex)
soBundle = soBundle.copy(
minSizeAlignment = ScrcpyPresets.MinSizeAlignment[idx]
)
},
valueRange = 0f..ScrcpyPresets.MinSizeAlignment.lastIndex.toFloat(),
steps = (ScrcpyPresets.MinSizeAlignment.size - 2).coerceAtLeast(0),
showKeyPoints = true,
keyPoints = ScrcpyPresets.MinSizeAlignment.indices.map { it.toFloat() },
displayText = soBundle.minSizeAlignment.toString(),
inputInitialValue = soBundle.minSizeAlignment
.takeIf { it != 1 }
?.toString()
?: "",
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..16f,
onInputConfirm = { raw ->
val parsed = raw.toIntOrNull() ?: return@SuperSlider
if (parsed and (parsed - 1) != 0) {
AppRuntime.snackbar(R.string.scrcpyopt_min_size_alignment_invalid)
return@SuperSlider
}
soBundle = soBundle.copy(
minSizeAlignment = parsed
)
},
)
SuperSlider( SuperSlider(
title = stringResource(R.string.scrcpyopt_max_size), title = stringResource(R.string.scrcpyopt_max_size),
summary = "--max-size", summary = "--max-size",
@@ -1433,6 +1484,20 @@ internal fun ScrcpyAllOptionsPage(
.fillMaxWidth() .fillMaxWidth()
.padding(all = UiSpacing.Large), .padding(all = UiSpacing.Large),
) )
SuperTextField(
value = cameraZoomInput,
onValueChange = { cameraZoomInput = it },
onFocusLost = {
soBundle = soBundle.copy(
cameraZoom = cameraZoomInput
)
},
label = "--camera-zoom",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(all = UiSpacing.Large),
)
SuperSlider( SuperSlider(
title = stringResource(R.string.scrcpyopt_camera_fps), title = stringResource(R.string.scrcpyopt_camera_fps),
summary = "--camera-fps", summary = "--camera-fps",
@@ -1472,6 +1537,16 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.scrcpyopt_camera_torch),
summary = "--camera-torch",
checked = soBundle.cameraTorch,
onCheckedChange = {
soBundle = soBundle.copy(
cameraTorch = it
)
},
)
} }
} }
@@ -1736,6 +1811,19 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.scrcpyopt_flex_display),
summary = "--flex-display",
checked = soBundle.flexDisplay,
onCheckedChange = {
soBundle = soBundle.copy(
flexDisplay = it
)
if (it) AppRuntime.snackbar(
"untested"
)
},
)
} }
} }

View File

@@ -40,6 +40,8 @@ data class ClientOptions(
var cameraSize: String = "", // to server var cameraSize: String = "", // to server
// --camera-ar // --camera-ar
var cameraAr: String = "", // to server var cameraAr: String = "", // to server
// --camera-zoom
var cameraZoom: String = "", // to server
// --camera-fps // --camera-fps
var cameraFps: UShort = 0u, // to server var cameraFps: UShort = 0u, // to server
@@ -74,6 +76,9 @@ data class ClientOptions(
// OR of enum sc_shortcut_mod values // OR of enum sc_shortcut_mod values
// var shortcutMods: ShortcutMod, // var shortcutMods: ShortcutMod,
// --min-size-alignment
var minSizeAlignment: UByte = 1u, // to server
// --max-size // --max-size
var maxSize: UShort = 0u, // to server var maxSize: UShort = 0u, // to server
@@ -207,6 +212,15 @@ data class ClientOptions(
var vdDestroyContent: Boolean = true, // to server var vdDestroyContent: Boolean = true, // to server
// --no-vd-system-decorations // --no-vd-system-decorations
var vdSystemDecorations: Boolean = true, // to server var vdSystemDecorations: Boolean = true, // to server
// --camera-torch
var cameraTorch: Boolean = false, // to server
// --keep-active
var keepActive: Boolean = false, // to server
// --flex-display
var flexDisplay: Boolean = false, // to server
) { ) {
enum class KeyInjectMode(val string: String) { enum class KeyInjectMode(val string: String) {
MIXED("mixed"), MIXED("mixed"),
@@ -372,6 +386,27 @@ data class ClientOptions(
) )
} }
if (flexDisplay) {
if (videoSource != VideoSource.DISPLAY || newDisplay.isBlank()) {
throw IllegalArgumentException(
"-x/--flex-display can only be applied to displays created " +
"with --new-display"
)
}
if (!control) {
throw IllegalArgumentException(
"-n/--no-control is not compatible with -x/--flex-display"
)
}
if (crop.isNotBlank()) {
throw IllegalArgumentException(
"--crop is not compatible with -x/--flex-display"
)
}
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED if (displayImePolicy != DisplayImePolicy.UNDEFINED
&& displayId == 0 && newDisplay.isBlank() && displayId == 0 && newDisplay.isBlank()
) { ) {
@@ -542,6 +577,11 @@ data class ClientOptions(
"Cannot start an Android app if control is disabled" "Cannot start an Android app if control is disabled"
) )
} }
if (keepActive) {
throw IllegalArgumentException(
"Cannot keep device active if control is disabled"
)
}
} }
return this return this
@@ -560,6 +600,7 @@ data class ClientOptions(
crop = crop, crop = crop,
maxSize = maxSize, maxSize = maxSize,
minSizeAlignment = minSizeAlignment,
videoBitRate = videoBitRate, videoBitRate = videoBitRate,
audioBitRate = audioBitRate, audioBitRate = audioBitRate,
maxFps = maxFps, maxFps = maxFps,
@@ -583,6 +624,7 @@ data class ClientOptions(
cameraId = cameraId, cameraId = cameraId,
cameraSize = cameraSize, cameraSize = cameraSize,
cameraAr = cameraAr, cameraAr = cameraAr,
cameraZoom = cameraZoom,
cameraFps = cameraFps, cameraFps = cameraFps,
powerOffOnClose = powerOffOnClose, powerOffOnClose = powerOffOnClose,
@@ -595,8 +637,11 @@ data class ClientOptions(
powerOn = powerOn, powerOn = powerOn,
cameraHighSpeed = cameraHighSpeed, cameraHighSpeed = cameraHighSpeed,
cameraTorch = cameraTorch,
vdDestroyContent = vdDestroyContent, vdDestroyContent = vdDestroyContent,
vdSystemDecorations = vdSystemDecorations, vdSystemDecorations = vdSystemDecorations,
keepActive = keepActive,
flexDisplay = flexDisplay,
list = list, list = list,
) )
} }

View File

@@ -42,6 +42,8 @@ import java.io.DataOutputStream
import java.io.EOFException import java.io.EOFException
import java.io.File import java.io.File
import java.io.InputStreamReader import java.io.InputStreamReader
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque import java.util.ArrayDeque
import java.util.Locale import java.util.Locale
import kotlin.concurrent.thread import kotlin.concurrent.thread
@@ -74,7 +76,10 @@ class Scrcpy(
private val lowLatency: Boolean = false, private val lowLatency: Boolean = false,
) { ) {
private val session = Session(::handleRemoteClipboardText) private val session = Session(
::handleRemoteClipboardText,
::updateCurrentSessionSize,
)
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clipboardSyncLock = Any() private val clipboardSyncLock = Any()
private val clipboardManager by lazy { private val clipboardManager by lazy {
@@ -99,6 +104,10 @@ class Scrcpy(
@Volatile @Volatile
private var isRunning: Boolean = false private var isRunning: Boolean = false
@Volatile
@JvmField
var flexDisplay: Boolean = false
@Volatile @Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null private var audioPlayer: ScrcpyAudioPlayer? = null
@@ -116,9 +125,9 @@ class Scrcpy(
companion object { companion object {
private const val TAG = "Scrcpy" private const val TAG = "Scrcpy"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4" const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0"
const val DEFAULT_SERVER_VERSION = "3.3.4" const val DEFAULT_SERVER_VERSION = "4.0"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
// Regex patterns for parsing server output // Regex patterns for parsing server output
@@ -197,11 +206,12 @@ class Scrcpy(
forwardKeyRepeat = options.forwardKeyRepeat, forwardKeyRepeat = options.forwardKeyRepeat,
) )
isRunning = true isRunning = true
flexDisplay = options.flexDisplay
startClipboardSync() startClipboardSync()
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) { if (options.video) {
NativeCoreFacade.onScrcpySessionStarted(info, session) NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy)
} }
// Setup audio player // Setup audio player
@@ -287,7 +297,10 @@ 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.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " + "video=${if (options.video) buildString {
append(info.codec?.string ?: "null")
if (info.width > 0 && info.height > 0) append(" ${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}"
) )
@@ -305,6 +318,7 @@ class Scrcpy(
runCatching { aacRecorder?.release() } runCatching { aacRecorder?.release() }
aacRecorder = null aacRecorder = null
isRunning = false isRunning = false
flexDisplay = false
_currentSessionState.value = null _currentSessionState.value = null
throw e throw e
} }
@@ -332,6 +346,7 @@ class Scrcpy(
audioPlayer?.release() audioPlayer?.release()
audioPlayer = null audioPlayer = null
isRunning = false isRunning = false
flexDisplay = false
_currentSessionState.value = null _currentSessionState.value = null
stopClipboardSync() stopClipboardSync()
Log.i(TAG, "stop(): Session stopped successfully") Log.i(TAG, "stop(): Session stopped successfully")
@@ -348,6 +363,10 @@ class Scrcpy(
session.startApp(name) session.startApp(name)
} }
suspend fun resizeDisplay(width: Int, height: Int) = withContext(Dispatchers.IO) {
session.resizeDisplay(width, height)
}
suspend fun injectKeycode( suspend fun injectKeycode(
action: Int, action: Int,
keycode: Int, keycode: Int,
@@ -423,6 +442,7 @@ class Scrcpy(
val current = _currentSessionState.value ?: return val current = _currentSessionState.value ?: return
if (current.width == width && current.height == height) return if (current.width == width && current.height == height) return
_currentSessionState.value = current.copy(width = width, height = height) _currentSessionState.value = current.copy(width = width, height = height)
NativeCoreFacade.onVideoSizeChanged(width, height)
} }
private fun startClipboardSync() { private fun startClipboardSync() {
@@ -970,6 +990,7 @@ class Scrcpy(
*/ */
class Session( class Session(
private val onRemoteClipboardText: (String) -> Unit, private val onRemoteClipboardText: (String) -> Unit,
private val onVideoSessionSize: (Int, Int) -> Unit,
) { ) {
private val mutex = Mutex() private val mutex = Mutex()
@@ -1085,20 +1106,17 @@ class Scrcpy(
} else { } else {
0 0
} }
val videoCodecId: Int val videoCodecId = if (options.video) {
val width: Int checkNotNull(videoInput).readInt()
val height: Int
if (options.video) {
val vInput = checkNotNull(videoInput)
videoCodecId = vInput.readInt()
width = vInput.readInt()
height = vInput.readInt()
} else { } else {
videoCodecId = 0 0
width = 0
height = 0
} }
// Video dimensions now come from the first session packet in the stream,
// not from stream metadata (v4.0 protocol change).
val width = 0
val height = 0
val sessionInfo = SessionInfo( val sessionInfo = SessionInfo(
deviceName = deviceName, deviceName = deviceName,
codecId = videoCodecId, codecId = videoCodecId,
@@ -1154,11 +1172,37 @@ class Scrcpy(
videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { videoReaderThread = thread(start = true, name = "scrcpy-video-reader") {
try { try {
val sessionFlagByte = (PACKET_FLAG_SESSION ushr 56).toInt()
val headerBuf = ByteArray(12)
while (activeSession === session && !vStream.closed) { while (activeSession === session && !vStream.closed) {
try { try {
val ptsAndFlags = vInput.readLong() vInput.readFully(headerBuf)
val packetSize = vInput.readInt()
if (packetSize <= 0) { if ((headerBuf[0].toInt() and sessionFlagByte) != 0) {
// Session packet: parse width/height, update state, no payload
val clientResized = (headerBuf[3].toInt() and 1) != 0
val sw = ((headerBuf[4].toInt() and 0xFF) shl 24) or
((headerBuf[5].toInt() and 0xFF) shl 16) or
((headerBuf[6].toInt() and 0xFF) shl 8) or
(headerBuf[7].toInt() and 0xFF)
val sh = ((headerBuf[8].toInt() and 0xFF) shl 24) or
((headerBuf[9].toInt() and 0xFF) shl 16) or
((headerBuf[10].toInt() and 0xFF) shl 8) or
(headerBuf[11].toInt() and 0xFF)
Log.i(
TAG,
"video session packet: ${sw}x${sh} clientResized=$clientResized"
)
onVideoSessionSize(sw, sh)
continue
}
// Media packet: parse ptsAndFlags + packetSize from header
val bb = ByteBuffer.wrap(headerBuf)
.order(ByteOrder.BIG_ENDIAN)
val ptsAndFlags = bb.long
val packetSize = bb.int
if (packetSize !in 1..10_000_000) {
continue continue
} }
@@ -1244,7 +1288,14 @@ class Scrcpy(
requireControlWriter().startApp(name) requireControlWriter().startApp(name)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Log.w(TAG, "startApp(): control channel not available", e) Log.w(TAG, "startApp(): control channel not available", e)
throw e }
}
suspend fun resizeDisplay(width: Int, height: Int) = mutex.withLock {
try {
requireControlWriter().resizeDisplay(width, height)
} catch (e: IllegalStateException) {
Log.w(TAG, "resizeDisplay(): control channel not available", e)
} }
} }
@@ -1625,7 +1676,7 @@ class Scrcpy(
fun setClipboard(text: String, paste: Boolean) { fun setClipboard(text: String, paste: Boolean) {
val bytes = text.toByteArray(Charsets.UTF_8) val bytes = text.toByteArray(Charsets.UTF_8)
output.writeByte(TYPE_SET_CLIPBOARD) output.writeByte(TYPE_SET_CLIPBOARD)
output.writeLong(CLIPBOARD_SEQUENCE_INVALID) output.writeLong(SEQUENCE_INVALID)
output.writeByte(if (paste) 1 else 0) output.writeByte(if (paste) 1 else 0)
output.writeInt(bytes.size) output.writeInt(bytes.size)
output.write(bytes) output.write(bytes)
@@ -1698,6 +1749,36 @@ class Scrcpy(
output.flush() output.flush()
} }
@Synchronized
fun setCameraTorch(on: Boolean) {
output.writeByte(TYPE_CAMERA_SET_TORCH)
output.writeBoolean(on)
output.flush()
}
@Synchronized
fun cameraZoomIn() {
output.writeByte(TYPE_CAMERA_ZOOM_IN)
output.flush()
}
@Synchronized
fun cameraZoomOut() {
output.writeByte(TYPE_CAMERA_ZOOM_OUT)
output.flush()
}
@Synchronized
fun resizeDisplay(width: Int, height: Int) {
require(width in 1..0xFFFF && height in 1..0xFFFF) {
"resizeDisplay dimensions must be in 1..65535, got ${width}x${height}"
}
output.writeByte(TYPE_RESIZE_DISPLAY)
output.writeShort(width)
output.writeShort(height)
output.flush()
}
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
output.writeInt(x) output.writeInt(x)
output.writeInt(y) output.writeInt(y)
@@ -1725,9 +1806,10 @@ class Scrcpy(
private const val CONNECT_RETRY_COUNT = 100 private const val CONNECT_RETRY_COUNT = 100
private const val CONNECT_RETRY_DELAY_MS = 100L private const val CONNECT_RETRY_DELAY_MS = 100L
private const val DEVICE_NAME_FIELD_LENGTH = 64 private const val DEVICE_NAME_FIELD_LENGTH = 64
private const val PACKET_FLAG_CONFIG = 1L shl 63 private const val PACKET_FLAG_SESSION = 1L shl 63
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 private const val PACKET_FLAG_CONFIG = 1L shl 62
private const val PACKET_PTS_MASK = (1L shl 62) - 1 private const val PACKET_FLAG_KEY_FRAME = 1L shl 61
private const val PACKET_PTS_MASK = (1L shl 61) - 1
private const val AUDIO_DISABLED = 0 private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1 private const val AUDIO_ERROR = 1
@@ -1741,10 +1823,29 @@ class Scrcpy(
private const val TYPE_INJECT_TOUCH_EVENT = 2 private const val TYPE_INJECT_TOUCH_EVENT = 2
private const val TYPE_INJECT_SCROLL_EVENT = 3 private const val TYPE_INJECT_SCROLL_EVENT = 3
private const val TYPE_BACK_OR_SCREEN_ON = 4 private const val TYPE_BACK_OR_SCREEN_ON = 4
private const val TYPE_EXPAND_NOTIFICATION_PANEL = 5
private const val TYPE_EXPAND_SETTINGS_PANEL = 6
private const val TYPE_COLLAPSE_PANELS = 7
private const val TYPE_GET_CLIPBOARD = 8
private const val TYPE_SET_CLIPBOARD = 9 private const val TYPE_SET_CLIPBOARD = 9
private const val TYPE_SET_DISPLAY_POWER = 10 private const val TYPE_SET_DISPLAY_POWER = 10
private const val TYPE_ROTATE_DEVICE = 11
private const val TYPE_UHID_CREATE = 12
private const val TYPE_UHID_INPUT = 13
private const val TYPE_UHID_DESTROY = 14
private const val TYPE_OPEN_HARD_KEYBOARD_SETTINGS = 15
private const val TYPE_START_APP = 16 private const val TYPE_START_APP = 16
private const val CLIPBOARD_SEQUENCE_INVALID = 0L private const val TYPE_RESET_VIDEO = 17
private const val TYPE_CAMERA_SET_TORCH = 18
private const val TYPE_CAMERA_ZOOM_IN = 19
private const val TYPE_CAMERA_ZOOM_OUT = 20
private const val TYPE_RESIZE_DISPLAY = 21
private const val SEQUENCE_INVALID = 0L
private const val COPY_KEY_NONE = 0
private const val COPY_KEY_COPY = 1
private const val COPY_KEY_CUT = 2
private fun socketNameFor(scid: Int): String { private fun socketNameFor(scid: Int): String {
return "scrcpy_%08x".format(scid) return "scrcpy_%08x".format(scid)

View File

@@ -14,84 +14,89 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// 启动 scrcpy 前直接继承自 [ClientOptions], // 启动 scrcpy 前直接继承自 [ClientOptions],
// 不需要默认值 // 不需要默认值
data class ServerParams( data class ServerParams(
var scid: UInt, // (random uint32) & 0x7FFFFFFF val scid: UInt, // (random uint32) & 0x7FFFFFFF
// var reqSerial: String, // val reqSerial: String,
var logLevel: LogLevel, val logLevel: LogLevel,
var videoCodec: Codec, val videoCodec: Codec,
var audioCodec: Codec, val audioCodec: Codec,
var videoSource: VideoSource, val videoSource: VideoSource,
var audioSource: AudioSource, val audioSource: AudioSource,
var cameraFacing: CameraFacing, val cameraFacing: CameraFacing,
var crop: String, val crop: String,
var videoCodecOptions: String, val videoCodecOptions: String,
var audioCodecOptions: String, val audioCodecOptions: String,
var videoEncoder: String, val videoEncoder: String,
var audioEncoder: String, val audioEncoder: String,
var cameraId: String, val cameraId: String,
var cameraSize: String, val cameraSize: String,
var cameraAr: String, // aspect ratio val cameraAr: String, // aspect ratio
var cameraFps: UShort, val cameraZoom: String,
val cameraFps: UShort,
// var portRange: PortRange, // sc_port_range(first, last) // val portRange: PortRange, // sc_port_range(first, last)
// var tunnelHost: UInt, // val tunnelHost: UInt,
// var tunnelPort: UShort, // val tunnelPort: UShort,
var maxSize: UShort, val maxSize: UShort,
val minSizeAlignment: UByte,
var videoBitRate: Int, val videoBitRate: Int,
var audioBitRate: Int, val audioBitRate: Int,
var maxFps: String, // float to be parsed by the server val maxFps: String, // float to be parsed by the server
var angle: String, // float to be parsed by the server val angle: String, // float to be parsed by the server
var screenOffTimeout: Tick, val screenOffTimeout: Tick,
var captureOrientation: Orientation, val captureOrientation: Orientation,
var captureOrientationLock: OrientationLock, val captureOrientationLock: OrientationLock,
var control: Boolean, val control: Boolean,
var displayId: Int, val displayId: Int,
var newDisplay: String, val newDisplay: String,
var displayImePolicy: DisplayImePolicy, val displayImePolicy: DisplayImePolicy,
var video: Boolean, val video: Boolean,
var audio: Boolean, val audio: Boolean,
var audioDup: Boolean, val audioDup: Boolean,
var showTouches: Boolean, val showTouches: Boolean,
var stayAwake: Boolean, val stayAwake: Boolean,
// var forceAdbForward: Boolean, // val forceAdbForward: Boolean,
var powerOffOnClose: Boolean, val powerOffOnClose: Boolean,
var legacyPaste: Boolean, val legacyPaste: Boolean,
var clipboardAutosync: Boolean, val clipboardAutosync: Boolean,
var downsizeOnError: Boolean, val downsizeOnError: Boolean,
// var tcpip: Boolean, // val tcpip: Boolean,
// var tcpipDst: String, // val tcpipDst: String,
// var selectUsb: Boolean, // val selectUsb: Boolean,
// var selectTcpip: Boolean, // val selectTcpip: Boolean,
var cleanUp: Boolean, val cleanUp: Boolean,
var powerOn: Boolean, val powerOn: Boolean,
// var killAdbOnClose: Boolean, // val killAdbOnClose: Boolean,
var cameraHighSpeed: Boolean, val cameraHighSpeed: Boolean,
val cameraTorch: Boolean,
var vdDestroyContent: Boolean, val vdDestroyContent: Boolean,
var vdSystemDecorations: Boolean, val vdSystemDecorations: Boolean,
val keepActive: Boolean,
val flexDisplay: Boolean,
var list: ListOptions, val list: ListOptions,
) { ) {
companion object { companion object {
const val SEPARATOR: String = " " const val SEPARATOR: String = " "
@@ -155,6 +160,9 @@ data class ServerParams(
validate(maxFps) validate(maxFps)
cmd.add("max_fps=${maxFps.trim()}") cmd.add("max_fps=${maxFps.trim()}")
} }
if (minSizeAlignment != 1.toUByte()) {
cmd.add("min_size_alignment=${minSizeAlignment}")
}
if (angle.isNotBlank()) { if (angle.isNotBlank()) {
validate(angle) validate(angle)
cmd.add("angle=${angle.trim()}") cmd.add("angle=${angle.trim()}")
@@ -207,6 +215,13 @@ data class ServerParams(
if (cameraHighSpeed) { if (cameraHighSpeed) {
cmd.add("camera_high_speed=true") cmd.add("camera_high_speed=true")
} }
if (cameraTorch) {
cmd.add("camera_torch=true")
}
if (cameraZoom.isNotBlank()) {
validate(cameraZoom)
cmd.add("camera_zoom=${cameraZoom.trim()}")
}
if (showTouches) { if (showTouches) {
cmd.add("show_touches=true") cmd.add("show_touches=true")
} }
@@ -259,6 +274,9 @@ data class ServerParams(
validate(newDisplay) validate(newDisplay)
cmd.add("new_display=${newDisplay.trim()}") cmd.add("new_display=${newDisplay.trim()}")
} }
if (flexDisplay) {
cmd.add("flex_display=true")
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) { if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
cmd.add("display_ime_policy=${displayImePolicy.string}") cmd.add("display_ime_policy=${displayImePolicy.string}")
} }
@@ -268,6 +286,9 @@ data class ServerParams(
if (!vdSystemDecorations) { if (!vdSystemDecorations) {
cmd.add("vd_system_decorations=false") cmd.add("vd_system_decorations=false")
} }
if (keepActive) {
cmd.add("keep_active=true")
}
if (list has ListOptions.ENCODERS) { if (list has ListOptions.ENCODERS) {
cmd.add("list_encoders=true") cmd.add("list_encoders=true")
} }

View File

@@ -24,6 +24,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.json.JSONObject
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object { companion object {
@@ -75,6 +76,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stringPreferencesKey("camera_ar"), stringPreferencesKey("camera_ar"),
"", "",
) )
val CAMERA_ZOOM = Pair(
stringPreferencesKey("camera_zoom"),
"",
)
val CAMERA_FPS = Pair( val CAMERA_FPS = Pair(
intPreferencesKey("camera_fps"), intPreferencesKey("camera_fps"),
0, 0,
@@ -107,6 +112,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stringPreferencesKey("camera_facing"), stringPreferencesKey("camera_facing"),
"any", "any",
) )
val MIN_SIZE_ALIGNMENT = Pair(
intPreferencesKey("min_size_alignment"),
1,
)
val MAX_SIZE = Pair( val MAX_SIZE = Pair(
intPreferencesKey("max_size"), intPreferencesKey("max_size"),
0, 0,
@@ -275,6 +284,18 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("vd_system_decorations"), booleanPreferencesKey("vd_system_decorations"),
true, true,
) )
val CAMERA_TORCH = Pair(
booleanPreferencesKey("camera_torch"),
false,
)
val KEEP_ACTIVE = Pair(
booleanPreferencesKey("keep_active"),
false,
)
val FLEX_DISPLAY = Pair(
booleanPreferencesKey("flex_display"),
false,
)
fun defaultBundle() = Bundle( fun defaultBundle() = Bundle(
crop = CROP.defaultValue, crop = CROP.defaultValue,
@@ -288,6 +309,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
cameraSizeCustom = CAMERA_SIZE_CUSTOM.defaultValue, cameraSizeCustom = CAMERA_SIZE_CUSTOM.defaultValue,
cameraSizeUseCustom = CAMERA_SIZE_USE_CUSTOM.defaultValue, cameraSizeUseCustom = CAMERA_SIZE_USE_CUSTOM.defaultValue,
cameraAr = CAMERA_AR.defaultValue, cameraAr = CAMERA_AR.defaultValue,
cameraZoom = CAMERA_ZOOM.defaultValue,
cameraFps = CAMERA_FPS.defaultValue, cameraFps = CAMERA_FPS.defaultValue,
logLevel = LOG_LEVEL.defaultValue, logLevel = LOG_LEVEL.defaultValue,
videoCodec = VIDEO_CODEC.defaultValue, videoCodec = VIDEO_CODEC.defaultValue,
@@ -296,6 +318,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
audioSource = AUDIO_SOURCE.defaultValue, audioSource = AUDIO_SOURCE.defaultValue,
recordFormat = RECORD_FORMAT.defaultValue, recordFormat = RECORD_FORMAT.defaultValue,
cameraFacing = CAMERA_FACING.defaultValue, cameraFacing = CAMERA_FACING.defaultValue,
minSizeAlignment = MIN_SIZE_ALIGNMENT.defaultValue,
maxSize = MAX_SIZE.defaultValue, maxSize = MAX_SIZE.defaultValue,
videoBitRate = VIDEO_BIT_RATE.defaultValue, videoBitRate = VIDEO_BIT_RATE.defaultValue,
audioBitRate = AUDIO_BIT_RATE.defaultValue, audioBitRate = AUDIO_BIT_RATE.defaultValue,
@@ -338,6 +361,9 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
startAppUseCustom = START_APP_USE_CUSTOM.defaultValue, startAppUseCustom = START_APP_USE_CUSTOM.defaultValue,
vdDestroyContent = VD_DESTROY_CONTENT.defaultValue, vdDestroyContent = VD_DESTROY_CONTENT.defaultValue,
vdSystemDecorations = VD_SYSTEM_DECORATIONS.defaultValue, vdSystemDecorations = VD_SYSTEM_DECORATIONS.defaultValue,
cameraTorch = CAMERA_TORCH.defaultValue,
keepActive = KEEP_ACTIVE.defaultValue,
flexDisplay = FLEX_DISPLAY.defaultValue,
) )
} }
@@ -354,6 +380,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val cameraSizeCustom: String, val cameraSizeCustom: String,
val cameraSizeUseCustom: Boolean, val cameraSizeUseCustom: Boolean,
val cameraAr: String, val cameraAr: String,
val cameraZoom: String,
val cameraFps: Int, val cameraFps: Int,
val logLevel: String, val logLevel: String,
val videoCodec: String, val videoCodec: String,
@@ -362,6 +389,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val audioSource: String, val audioSource: String,
val recordFormat: String, val recordFormat: String,
val cameraFacing: String, val cameraFacing: String,
val minSizeAlignment: Int,
val maxSize: Int, val maxSize: Int,
val videoBitRate: Int, val videoBitRate: Int,
val audioBitRate: Int, val audioBitRate: Int,
@@ -403,7 +431,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val startAppCustom: String, val startAppCustom: String,
val startAppUseCustom: Boolean, val startAppUseCustom: Boolean,
val vdDestroyContent: Boolean, val vdDestroyContent: Boolean,
val vdSystemDecorations: Boolean val vdSystemDecorations: Boolean,
val cameraTorch: Boolean,
val keepActive: Boolean,
val flexDisplay: Boolean,
) : Parcelable { ) : Parcelable {
} }
@@ -419,6 +450,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
bundleField(CAMERA_SIZE_CUSTOM) { it.cameraSizeCustom }, bundleField(CAMERA_SIZE_CUSTOM) { it.cameraSizeCustom },
bundleField(CAMERA_SIZE_USE_CUSTOM) { it.cameraSizeUseCustom }, bundleField(CAMERA_SIZE_USE_CUSTOM) { it.cameraSizeUseCustom },
bundleField(CAMERA_AR) { it.cameraAr }, bundleField(CAMERA_AR) { it.cameraAr },
bundleField(CAMERA_ZOOM) { it.cameraZoom },
bundleField(CAMERA_FPS) { it.cameraFps }, bundleField(CAMERA_FPS) { it.cameraFps },
bundleField(LOG_LEVEL) { it.logLevel }, bundleField(LOG_LEVEL) { it.logLevel },
bundleField(VIDEO_CODEC) { it.videoCodec }, bundleField(VIDEO_CODEC) { it.videoCodec },
@@ -427,6 +459,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
bundleField(AUDIO_SOURCE) { it.audioSource }, bundleField(AUDIO_SOURCE) { it.audioSource },
bundleField(RECORD_FORMAT) { it.recordFormat }, bundleField(RECORD_FORMAT) { it.recordFormat },
bundleField(CAMERA_FACING) { it.cameraFacing }, bundleField(CAMERA_FACING) { it.cameraFacing },
bundleField(MIN_SIZE_ALIGNMENT) { it.minSizeAlignment },
bundleField(MAX_SIZE) { it.maxSize }, bundleField(MAX_SIZE) { it.maxSize },
bundleField(VIDEO_BIT_RATE) { it.videoBitRate }, bundleField(VIDEO_BIT_RATE) { it.videoBitRate },
bundleField(AUDIO_BIT_RATE) { it.audioBitRate }, bundleField(AUDIO_BIT_RATE) { it.audioBitRate },
@@ -468,6 +501,9 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
bundleField(START_APP_USE_CUSTOM) { it.startAppUseCustom }, bundleField(START_APP_USE_CUSTOM) { it.startAppUseCustom },
bundleField(VD_DESTROY_CONTENT) { it.vdDestroyContent }, bundleField(VD_DESTROY_CONTENT) { it.vdDestroyContent },
bundleField(VD_SYSTEM_DECORATIONS) { it.vdSystemDecorations }, bundleField(VD_SYSTEM_DECORATIONS) { it.vdSystemDecorations },
bundleField(CAMERA_TORCH) { it.cameraTorch },
bundleField(KEEP_ACTIVE) { it.keepActive },
bundleField(FLEX_DISPLAY) { it.flexDisplay },
) )
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences) val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -484,6 +520,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM), cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM),
cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM), cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM),
cameraAr = preferences.read(CAMERA_AR), cameraAr = preferences.read(CAMERA_AR),
cameraZoom = preferences.read(CAMERA_ZOOM),
cameraFps = preferences.read(CAMERA_FPS), cameraFps = preferences.read(CAMERA_FPS),
logLevel = preferences.read(LOG_LEVEL), logLevel = preferences.read(LOG_LEVEL),
videoCodec = preferences.read(VIDEO_CODEC), videoCodec = preferences.read(VIDEO_CODEC),
@@ -492,6 +529,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
audioSource = preferences.read(AUDIO_SOURCE), audioSource = preferences.read(AUDIO_SOURCE),
recordFormat = preferences.read(RECORD_FORMAT), recordFormat = preferences.read(RECORD_FORMAT),
cameraFacing = preferences.read(CAMERA_FACING), cameraFacing = preferences.read(CAMERA_FACING),
minSizeAlignment = preferences.read(MIN_SIZE_ALIGNMENT),
maxSize = preferences.read(MAX_SIZE), maxSize = preferences.read(MAX_SIZE),
videoBitRate = preferences.read(VIDEO_BIT_RATE), videoBitRate = preferences.read(VIDEO_BIT_RATE),
audioBitRate = preferences.read(AUDIO_BIT_RATE), audioBitRate = preferences.read(AUDIO_BIT_RATE),
@@ -534,6 +572,9 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
startAppUseCustom = preferences.read(START_APP_USE_CUSTOM), startAppUseCustom = preferences.read(START_APP_USE_CUSTOM),
vdDestroyContent = preferences.read(VD_DESTROY_CONTENT), vdDestroyContent = preferences.read(VD_DESTROY_CONTENT),
vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS), vdSystemDecorations = preferences.read(VD_SYSTEM_DECORATIONS),
cameraTorch = preferences.read(CAMERA_TORCH),
keepActive = preferences.read(KEEP_ACTIVE),
flexDisplay = preferences.read(FLEX_DISPLAY),
) )
suspend fun loadBundle() = loadBundle(::bundleFromPreferences) suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
@@ -564,6 +605,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
cameraId = bundle.cameraId, cameraId = bundle.cameraId,
cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom, cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom,
cameraAr = bundle.cameraAr, cameraAr = bundle.cameraAr,
cameraZoom = bundle.cameraZoom,
cameraFps = bundle.cameraFps.toUShort(), cameraFps = bundle.cameraFps.toUShort(),
logLevel = LogLevel.fromString(bundle.logLevel), logLevel = LogLevel.fromString(bundle.logLevel),
videoCodec = Codec.fromString(bundle.videoCodec), videoCodec = Codec.fromString(bundle.videoCodec),
@@ -572,6 +614,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
audioSource = AudioSource.fromString(bundle.audioSource), audioSource = AudioSource.fromString(bundle.audioSource),
recordFormat = RecordFormat.fromString(bundle.recordFormat), recordFormat = RecordFormat.fromString(bundle.recordFormat),
cameraFacing = CameraFacing.fromString(bundle.cameraFacing), cameraFacing = CameraFacing.fromString(bundle.cameraFacing),
minSizeAlignment = bundle.minSizeAlignment.toUByte(),
maxSize = bundle.maxSize.toUShort(), maxSize = bundle.maxSize.toUShort(),
videoBitRate = bundle.videoBitRate, videoBitRate = bundle.videoBitRate,
audioBitRate = bundle.audioBitRate, audioBitRate = bundle.audioBitRate,
@@ -611,6 +654,360 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
newDisplay = bundle.newDisplay, newDisplay = bundle.newDisplay,
startApp = if (!bundle.startAppUseCustom) bundle.startApp else bundle.startAppCustom, startApp = if (!bundle.startAppUseCustom) bundle.startApp else bundle.startAppCustom,
vdDestroyContent = bundle.vdDestroyContent, vdDestroyContent = bundle.vdDestroyContent,
vdSystemDecorations = bundle.vdSystemDecorations vdSystemDecorations = bundle.vdSystemDecorations,
cameraTorch = bundle.cameraTorch,
keepActive = bundle.keepActive,
flexDisplay = bundle.flexDisplay,
) )
} }
internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
JSONObject()
.put("crop", bundle.crop)
.put("recordFilename", bundle.recordFilename)
.put("videoCodecOptions", bundle.videoCodecOptions)
.put("audioCodecOptions", bundle.audioCodecOptions)
.put("videoEncoder", bundle.videoEncoder)
.put("audioEncoder", bundle.audioEncoder)
.put("cameraId", bundle.cameraId)
.put("cameraSize", bundle.cameraSize)
.put("cameraSizeCustom", bundle.cameraSizeCustom)
.put("cameraSizeUseCustom", bundle.cameraSizeUseCustom)
.put("cameraAr", bundle.cameraAr)
.put("cameraZoom", bundle.cameraZoom)
.put("cameraFps", bundle.cameraFps)
.put("logLevel", bundle.logLevel)
.put("videoCodec", bundle.videoCodec)
.put("audioCodec", bundle.audioCodec)
.put("videoSource", bundle.videoSource)
.put("audioSource", bundle.audioSource)
.put("recordFormat", bundle.recordFormat)
.put("cameraFacing", bundle.cameraFacing)
.put("minSizeAlignment", bundle.minSizeAlignment)
.put("maxSize", bundle.maxSize)
.put("videoBitRate", bundle.videoBitRate)
.put("audioBitRate", bundle.audioBitRate)
.put("maxFps", bundle.maxFps)
.put("angle", bundle.angle)
.put("captureOrientation", bundle.captureOrientation)
.put("captureOrientationLock", bundle.captureOrientationLock)
.put("displayOrientation", bundle.displayOrientation)
.put("recordOrientation", bundle.recordOrientation)
.put("displayImePolicy", bundle.displayImePolicy)
.put("displayId", bundle.displayId)
.put("screenOffTimeout", bundle.screenOffTimeout)
.put("showTouches", bundle.showTouches)
.put("fullscreen", bundle.fullscreen)
.put("control", bundle.control)
.put("videoPlayback", bundle.videoPlayback)
.put("audioPlayback", bundle.audioPlayback)
.put("turnScreenOff", bundle.turnScreenOff)
.put("keyInjectMode", bundle.keyInjectMode)
.put("forwardKeyRepeat", bundle.forwardKeyRepeat)
.put("stayAwake", bundle.stayAwake)
.put("disableScreensaver", bundle.disableScreensaver)
.put("powerOffOnClose", bundle.powerOffOnClose)
.put("legacyPaste", bundle.legacyPaste)
.put("clipboardAutosync", bundle.clipboardAutosync)
.put("downsizeOnError", bundle.downsizeOnError)
.put("mouseHover", bundle.mouseHover)
.put("cleanup", bundle.cleanup)
.put("powerOn", bundle.powerOn)
.put("video", bundle.video)
.put("audio", bundle.audio)
.put("requireAudio", bundle.requireAudio)
.put("killAdbOnClose", bundle.killAdbOnClose)
.put("cameraHighSpeed", bundle.cameraHighSpeed)
.put("list", bundle.list)
.put("audioDup", bundle.audioDup)
.put("newDisplay", bundle.newDisplay)
.put("startApp", bundle.startApp)
.put("startAppCustom", bundle.startAppCustom)
.put("startAppUseCustom", bundle.startAppUseCustom)
.put("vdDestroyContent", bundle.vdDestroyContent)
.put("vdSystemDecorations", bundle.vdSystemDecorations)
.put("cameraTorch", bundle.cameraTorch)
.put("keepActive", bundle.keepActive)
.put("flexDisplay", bundle.flexDisplay)
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
return ScrcpyOptions.defaultBundle().copy(
crop = json.optStringOrDefault(
"crop",
ScrcpyOptions.CROP.defaultValue,
),
recordFilename = json.optStringOrDefault(
"recordFilename",
ScrcpyOptions.RECORD_FILENAME.defaultValue,
),
videoCodecOptions = json.optStringOrDefault(
"videoCodecOptions",
ScrcpyOptions.VIDEO_CODEC_OPTIONS.defaultValue,
),
audioCodecOptions = json.optStringOrDefault(
"audioCodecOptions",
ScrcpyOptions.AUDIO_CODEC_OPTIONS.defaultValue,
),
videoEncoder = json.optStringOrDefault(
"videoEncoder",
ScrcpyOptions.VIDEO_ENCODER.defaultValue,
),
audioEncoder = json.optStringOrDefault(
"audioEncoder",
ScrcpyOptions.AUDIO_ENCODER.defaultValue,
),
cameraId = json.optStringOrDefault(
"cameraId",
ScrcpyOptions.CAMERA_ID.defaultValue,
),
cameraSize = json.optStringOrDefault(
"cameraSize",
ScrcpyOptions.CAMERA_SIZE.defaultValue,
),
cameraSizeCustom = json.optStringOrDefault(
"cameraSizeCustom",
ScrcpyOptions.CAMERA_SIZE_CUSTOM.defaultValue,
),
cameraSizeUseCustom = json.optBooleanOrDefault(
"cameraSizeUseCustom",
ScrcpyOptions.CAMERA_SIZE_USE_CUSTOM.defaultValue,
),
cameraAr = json.optStringOrDefault(
"cameraAr",
ScrcpyOptions.CAMERA_AR.defaultValue,
),
cameraZoom = json.optStringOrDefault(
"cameraZoom",
ScrcpyOptions.CAMERA_ZOOM.defaultValue,
),
cameraFps = json.optIntOrDefault(
"cameraFps",
ScrcpyOptions.CAMERA_FPS.defaultValue,
),
logLevel = json.optStringOrDefault(
"logLevel",
ScrcpyOptions.LOG_LEVEL.defaultValue,
),
videoCodec = json.optStringOrDefault(
"videoCodec",
ScrcpyOptions.VIDEO_CODEC.defaultValue,
),
audioCodec = json.optStringOrDefault(
"audioCodec",
ScrcpyOptions.AUDIO_CODEC.defaultValue,
),
videoSource = json.optStringOrDefault(
"videoSource",
ScrcpyOptions.VIDEO_SOURCE.defaultValue,
),
audioSource = json.optStringOrDefault(
"audioSource",
ScrcpyOptions.AUDIO_SOURCE.defaultValue,
),
recordFormat = json.optStringOrDefault(
"recordFormat",
ScrcpyOptions.RECORD_FORMAT.defaultValue,
),
cameraFacing = json.optStringOrDefault(
"cameraFacing",
ScrcpyOptions.CAMERA_FACING.defaultValue,
),
minSizeAlignment = json.optIntOrDefault(
"minSizeAlignment",
ScrcpyOptions.MIN_SIZE_ALIGNMENT.defaultValue,
),
maxSize = json.optIntOrDefault(
"maxSize",
ScrcpyOptions.MAX_SIZE.defaultValue,
),
videoBitRate = json.optIntOrDefault(
"videoBitRate",
ScrcpyOptions.VIDEO_BIT_RATE.defaultValue,
),
audioBitRate = json.optIntOrDefault(
"audioBitRate",
ScrcpyOptions.AUDIO_BIT_RATE.defaultValue,
),
maxFps = json.optStringOrDefault(
"maxFps",
ScrcpyOptions.MAX_FPS.defaultValue,
),
angle = json.optStringOrDefault(
"angle",
ScrcpyOptions.ANGLE.defaultValue,
),
captureOrientation = json.optIntOrDefault(
"captureOrientation",
ScrcpyOptions.CAPTURE_ORIENTATION.defaultValue,
),
captureOrientationLock = json.optStringOrDefault(
"captureOrientationLock",
ScrcpyOptions.CAPTURE_ORIENTATION_LOCK.defaultValue,
),
displayOrientation = json.optIntOrDefault(
"displayOrientation",
ScrcpyOptions.DISPLAY_ORIENTATION.defaultValue,
),
recordOrientation = json.optIntOrDefault(
"recordOrientation",
ScrcpyOptions.RECORD_ORIENTATION.defaultValue,
),
displayImePolicy = json.optStringOrDefault(
"displayImePolicy",
ScrcpyOptions.DISPLAY_IME_POLICY.defaultValue,
),
displayId = json.optIntOrDefault(
"displayId",
ScrcpyOptions.DISPLAY_ID.defaultValue,
),
screenOffTimeout = json.optLongOrDefault(
"screenOffTimeout",
ScrcpyOptions.SCREEN_OFF_TIMEOUT.defaultValue,
),
showTouches = json.optBooleanOrDefault(
"showTouches",
ScrcpyOptions.SHOW_TOUCHES.defaultValue,
),
fullscreen = json.optBooleanOrDefault(
"fullscreen",
ScrcpyOptions.FULLSCREEN.defaultValue,
),
control = json.optBooleanOrDefault(
"control",
ScrcpyOptions.CONTROL.defaultValue,
),
videoPlayback = json.optBooleanOrDefault(
"videoPlayback",
ScrcpyOptions.VIDEO_PLAYBACK.defaultValue,
),
audioPlayback = json.optBooleanOrDefault(
"audioPlayback",
ScrcpyOptions.AUDIO_PLAYBACK.defaultValue,
),
turnScreenOff = json.optBooleanOrDefault(
"turnScreenOff",
ScrcpyOptions.TURN_SCREEN_OFF.defaultValue,
),
keyInjectMode = json.optStringOrDefault(
"keyInjectMode",
ScrcpyOptions.KEY_INJECT_MODE.defaultValue,
),
forwardKeyRepeat = json.optBooleanOrDefault(
"forwardKeyRepeat",
ScrcpyOptions.FORWARD_KEY_REPEAT.defaultValue,
),
stayAwake = json.optBooleanOrDefault(
"stayAwake",
ScrcpyOptions.STAY_AWAKE.defaultValue,
),
disableScreensaver = json.optBooleanOrDefault(
"disableScreensaver",
ScrcpyOptions.DISABLE_SCREENSAVER.defaultValue,
),
powerOffOnClose = json.optBooleanOrDefault(
"powerOffOnClose",
ScrcpyOptions.POWER_OFF_ON_CLOSE.defaultValue,
),
legacyPaste = json.optBooleanOrDefault(
"legacyPaste",
ScrcpyOptions.LEGACY_PASTE.defaultValue,
),
clipboardAutosync = json.optBooleanOrDefault(
"clipboardAutosync",
ScrcpyOptions.CLIPBOARD_AUTOSYNC.defaultValue,
),
downsizeOnError = json.optBooleanOrDefault(
"downsizeOnError",
ScrcpyOptions.DOWNSIZE_ON_ERROR.defaultValue,
),
mouseHover = json.optBooleanOrDefault(
"mouseHover",
ScrcpyOptions.MOUSE_HOVER.defaultValue,
),
cleanup = json.optBooleanOrDefault(
"cleanup",
ScrcpyOptions.CLEANUP.defaultValue,
),
powerOn = json.optBooleanOrDefault(
"powerOn",
ScrcpyOptions.POWER_ON.defaultValue,
),
video = json.optBooleanOrDefault(
"video",
ScrcpyOptions.VIDEO.defaultValue,
),
audio = json.optBooleanOrDefault(
"audio",
ScrcpyOptions.AUDIO.defaultValue,
),
requireAudio = json.optBooleanOrDefault(
"requireAudio",
ScrcpyOptions.REQUIRE_AUDIO.defaultValue,
),
killAdbOnClose = json.optBooleanOrDefault(
"killAdbOnClose",
ScrcpyOptions.KILL_ADB_ON_CLOSE.defaultValue,
),
cameraHighSpeed = json.optBooleanOrDefault(
"cameraHighSpeed",
ScrcpyOptions.CAMERA_HIGH_SPEED.defaultValue,
),
list = json.optStringOrDefault(
"list",
ScrcpyOptions.LIST.defaultValue,
),
audioDup = json.optBooleanOrDefault(
"audioDup",
ScrcpyOptions.AUDIO_DUP.defaultValue,
),
newDisplay = json.optStringOrDefault(
"newDisplay",
ScrcpyOptions.NEW_DISPLAY.defaultValue,
),
startApp = json.optStringOrDefault(
"startApp",
ScrcpyOptions.START_APP.defaultValue,
),
startAppCustom = json.optStringOrDefault(
"startAppCustom",
ScrcpyOptions.START_APP_CUSTOM.defaultValue,
),
startAppUseCustom = json.optBooleanOrDefault(
"startAppUseCustom",
ScrcpyOptions.START_APP_USE_CUSTOM.defaultValue,
),
vdDestroyContent = json.optBooleanOrDefault(
"vdDestroyContent",
ScrcpyOptions.VD_DESTROY_CONTENT.defaultValue,
),
vdSystemDecorations = json.optBooleanOrDefault(
"vdSystemDecorations",
ScrcpyOptions.VD_SYSTEM_DECORATIONS.defaultValue,
),
cameraTorch = json.optBooleanOrDefault(
"cameraTorch",
ScrcpyOptions.CAMERA_TORCH.defaultValue,
),
keepActive = json.optBooleanOrDefault(
"keepActive",
ScrcpyOptions.KEEP_ACTIVE.defaultValue,
),
flexDisplay = json.optBooleanOrDefault(
"flexDisplay",
ScrcpyOptions.FLEX_DISPLAY.defaultValue,
),
)
}
private fun JSONObject.optStringOrDefault(key: String, defaultValue: String): String =
if (has(key) && !isNull(key)) optString(key, defaultValue) else defaultValue
private fun JSONObject.optBooleanOrDefault(key: String, defaultValue: Boolean): Boolean =
if (has(key) && !isNull(key)) optBoolean(key, defaultValue) else defaultValue
private fun JSONObject.optIntOrDefault(key: String, defaultValue: Int): Int =
if (has(key) && !isNull(key)) optInt(key, defaultValue) else defaultValue
private fun JSONObject.optLongOrDefault(key: String, defaultValue: Long): Long =
if (has(key) && !isNull(key)) optLong(key, defaultValue) else defaultValue

View File

@@ -213,7 +213,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
JSONObject() JSONObject()
.put("id", profile.id) .put("id", profile.id)
.put("name", profile.name) .put("name", profile.name)
.put("bundle", encodeBundle(profile.bundle)) .put("bundle", encodeBundleToJson(profile.bundle))
) )
} }
return array.toString() return array.toString()
@@ -234,7 +234,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
Profile( Profile(
id = id, id = id,
name = name.ifBlank { textNewProfile }, name = name.ifBlank { textNewProfile },
bundle = decodeBundle(item.optJSONObject("bundle")), bundle = decodeBundleFromJson(item.optJSONObject("bundle")),
isBuiltinGlobal = id == ScrcpyOptions.GLOBAL_PROFILE_ID, isBuiltinGlobal = id == ScrcpyOptions.GLOBAL_PROFILE_ID,
) )
) )
@@ -242,331 +242,4 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
} }
return State(profiles) return State(profiles)
} }
}
private fun encodeBundle(bundle: ScrcpyOptions.Bundle): JSONObject =
JSONObject()
.put("crop", bundle.crop)
.put("recordFilename", bundle.recordFilename)
.put("videoCodecOptions", bundle.videoCodecOptions)
.put("audioCodecOptions", bundle.audioCodecOptions)
.put("videoEncoder", bundle.videoEncoder)
.put("audioEncoder", bundle.audioEncoder)
.put("cameraId", bundle.cameraId)
.put("cameraSize", bundle.cameraSize)
.put("cameraSizeCustom", bundle.cameraSizeCustom)
.put("cameraSizeUseCustom", bundle.cameraSizeUseCustom)
.put("cameraAr", bundle.cameraAr)
.put("cameraFps", bundle.cameraFps)
.put("logLevel", bundle.logLevel)
.put("videoCodec", bundle.videoCodec)
.put("audioCodec", bundle.audioCodec)
.put("videoSource", bundle.videoSource)
.put("audioSource", bundle.audioSource)
.put("recordFormat", bundle.recordFormat)
.put("cameraFacing", bundle.cameraFacing)
.put("maxSize", bundle.maxSize)
.put("videoBitRate", bundle.videoBitRate)
.put("audioBitRate", bundle.audioBitRate)
.put("maxFps", bundle.maxFps)
.put("angle", bundle.angle)
.put("captureOrientation", bundle.captureOrientation)
.put("captureOrientationLock", bundle.captureOrientationLock)
.put("displayOrientation", bundle.displayOrientation)
.put("recordOrientation", bundle.recordOrientation)
.put("displayImePolicy", bundle.displayImePolicy)
.put("displayId", bundle.displayId)
.put("screenOffTimeout", bundle.screenOffTimeout)
.put("showTouches", bundle.showTouches)
.put("fullscreen", bundle.fullscreen)
.put("control", bundle.control)
.put("videoPlayback", bundle.videoPlayback)
.put("audioPlayback", bundle.audioPlayback)
.put("turnScreenOff", bundle.turnScreenOff)
.put("keyInjectMode", bundle.keyInjectMode)
.put("forwardKeyRepeat", bundle.forwardKeyRepeat)
.put("stayAwake", bundle.stayAwake)
.put("disableScreensaver", bundle.disableScreensaver)
.put("powerOffOnClose", bundle.powerOffOnClose)
.put("legacyPaste", bundle.legacyPaste)
.put("clipboardAutosync", bundle.clipboardAutosync)
.put("downsizeOnError", bundle.downsizeOnError)
.put("mouseHover", bundle.mouseHover)
.put("cleanup", bundle.cleanup)
.put("powerOn", bundle.powerOn)
.put("video", bundle.video)
.put("audio", bundle.audio)
.put("requireAudio", bundle.requireAudio)
.put("killAdbOnClose", bundle.killAdbOnClose)
.put("cameraHighSpeed", bundle.cameraHighSpeed)
.put("list", bundle.list)
.put("audioDup", bundle.audioDup)
.put("newDisplay", bundle.newDisplay)
.put("startApp", bundle.startApp)
.put("startAppCustom", bundle.startAppCustom)
.put("startAppUseCustom", bundle.startAppUseCustom)
.put("vdDestroyContent", bundle.vdDestroyContent)
.put("vdSystemDecorations", bundle.vdSystemDecorations)
private fun decodeBundle(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
val defaults = ScrcpyOptions.defaultBundle()
val json = bundleJson ?: return defaults
return defaults.copy(
crop = json.optStringOrDefault(
"crop",
defaults.crop,
),
recordFilename = json.optStringOrDefault(
"recordFilename",
defaults.recordFilename,
),
videoCodecOptions = json.optStringOrDefault(
"videoCodecOptions",
defaults.videoCodecOptions,
),
audioCodecOptions = json.optStringOrDefault(
"audioCodecOptions",
defaults.audioCodecOptions,
),
videoEncoder = json.optStringOrDefault(
"videoEncoder",
defaults.videoEncoder,
),
audioEncoder = json.optStringOrDefault(
"audioEncoder",
defaults.audioEncoder,
),
cameraId = json.optStringOrDefault(
"cameraId",
defaults.cameraId,
),
cameraSize = json.optStringOrDefault(
"cameraSize",
defaults.cameraSize,
),
cameraSizeCustom = json.optStringOrDefault(
"cameraSizeCustom",
defaults.cameraSizeCustom,
),
cameraSizeUseCustom = json.optBooleanOrDefault(
"cameraSizeUseCustom",
defaults.cameraSizeUseCustom,
),
cameraAr = json.optStringOrDefault(
"cameraAr",
defaults.cameraAr,
),
cameraFps = json.optIntOrDefault(
"cameraFps",
defaults.cameraFps,
),
logLevel = json.optStringOrDefault(
"logLevel",
defaults.logLevel,
),
videoCodec = json.optStringOrDefault(
"videoCodec",
defaults.videoCodec,
),
audioCodec = json.optStringOrDefault(
"audioCodec",
defaults.audioCodec,
),
videoSource = json.optStringOrDefault(
"videoSource",
defaults.videoSource,
),
audioSource = json.optStringOrDefault(
"audioSource",
defaults.audioSource,
),
recordFormat = json.optStringOrDefault(
"recordFormat",
defaults.recordFormat,
),
cameraFacing = json.optStringOrDefault(
"cameraFacing",
defaults.cameraFacing,
),
maxSize = json.optIntOrDefault(
"maxSize",
defaults.maxSize,
),
videoBitRate = json.optIntOrDefault(
"videoBitRate",
defaults.videoBitRate,
),
audioBitRate = json.optIntOrDefault(
"audioBitRate",
defaults.audioBitRate,
),
maxFps = json.optStringOrDefault(
"maxFps",
defaults.maxFps,
),
angle = json.optStringOrDefault(
"angle",
defaults.angle,
),
captureOrientation = json.optIntOrDefault(
"captureOrientation",
defaults.captureOrientation,
),
captureOrientationLock = json.optStringOrDefault(
"captureOrientationLock",
defaults.captureOrientationLock,
),
displayOrientation = json.optIntOrDefault(
"displayOrientation",
defaults.displayOrientation,
),
recordOrientation = json.optIntOrDefault(
"recordOrientation",
defaults.recordOrientation,
),
displayImePolicy = json.optStringOrDefault(
"displayImePolicy",
defaults.displayImePolicy,
),
displayId = json.optIntOrDefault(
"displayId",
defaults.displayId,
),
screenOffTimeout = json.optLongOrDefault(
"screenOffTimeout",
defaults.screenOffTimeout,
),
showTouches = json.optBooleanOrDefault(
"showTouches",
defaults.showTouches,
),
fullscreen = json.optBooleanOrDefault(
"fullscreen",
defaults.fullscreen,
),
control = json.optBooleanOrDefault(
"control",
defaults.control,
),
videoPlayback = json.optBooleanOrDefault(
"videoPlayback",
defaults.videoPlayback,
),
audioPlayback = json.optBooleanOrDefault(
"audioPlayback",
defaults.audioPlayback,
),
turnScreenOff = json.optBooleanOrDefault(
"turnScreenOff",
defaults.turnScreenOff,
),
keyInjectMode = json.optStringOrDefault(
"keyInjectMode",
defaults.keyInjectMode,
),
forwardKeyRepeat = json.optBooleanOrDefault(
"forwardKeyRepeat",
defaults.forwardKeyRepeat,
),
stayAwake = json.optBooleanOrDefault(
"stayAwake",
defaults.stayAwake,
),
disableScreensaver = json.optBooleanOrDefault(
"disableScreensaver",
defaults.disableScreensaver,
),
powerOffOnClose = json.optBooleanOrDefault(
"powerOffOnClose",
defaults.powerOffOnClose,
),
legacyPaste = json.optBooleanOrDefault(
"legacyPaste",
defaults.legacyPaste,
),
clipboardAutosync = json.optBooleanOrDefault(
"clipboardAutosync",
defaults.clipboardAutosync,
),
downsizeOnError = json.optBooleanOrDefault(
"downsizeOnError",
defaults.downsizeOnError,
),
mouseHover = json.optBooleanOrDefault(
"mouseHover",
defaults.mouseHover,
),
cleanup = json.optBooleanOrDefault(
"cleanup",
defaults.cleanup,
),
powerOn = json.optBooleanOrDefault(
"powerOn",
defaults.powerOn,
),
video = json.optBooleanOrDefault(
"video",
defaults.video,
),
audio = json.optBooleanOrDefault(
"audio",
defaults.audio,
),
requireAudio = json.optBooleanOrDefault(
"requireAudio",
defaults.requireAudio,
),
killAdbOnClose = json.optBooleanOrDefault(
"killAdbOnClose",
defaults.killAdbOnClose,
),
cameraHighSpeed = json.optBooleanOrDefault(
"cameraHighSpeed",
defaults.cameraHighSpeed,
),
list = json.optStringOrDefault(
"list",
defaults.list,
),
audioDup = json.optBooleanOrDefault(
"audioDup",
defaults.audioDup,
),
newDisplay = json.optStringOrDefault(
"newDisplay",
defaults.newDisplay,
),
startApp = json.optStringOrDefault(
"startApp",
defaults.startApp,
),
startAppCustom = json.optStringOrDefault(
"startAppCustom",
defaults.startAppCustom,
),
startAppUseCustom = json.optBooleanOrDefault(
"startAppUseCustom",
defaults.startAppUseCustom,
),
vdDestroyContent = json.optBooleanOrDefault(
"vdDestroyContent",
defaults.vdDestroyContent,
),
vdSystemDecorations = json.optBooleanOrDefault(
"vdSystemDecorations",
defaults.vdSystemDecorations,
),
)
}
private fun JSONObject.optStringOrDefault(key: String, defaultValue: String): String =
if (has(key) && !isNull(key)) optString(key, defaultValue) else defaultValue
private fun JSONObject.optBooleanOrDefault(key: String, defaultValue: Boolean): Boolean =
if (has(key) && !isNull(key)) optBoolean(key, defaultValue) else defaultValue
private fun JSONObject.optIntOrDefault(key: String, defaultValue: Int): Int =
if (has(key) && !isNull(key)) optInt(key, defaultValue) else defaultValue
private fun JSONObject.optLongOrDefault(key: String, defaultValue: Long): Long =
if (has(key) && !isNull(key)) optLong(key, defaultValue) else defaultValue
}

View File

@@ -0,0 +1,35 @@
package io.github.miuzarte.scrcpyforandroid.util
import android.os.Handler
import android.os.Looper
import kotlin.math.roundToInt
/**
* Simple debouncer that delays execution of [block] until [delayMs]
* milliseconds have elapsed since the last call to [invoke].
*
* All calls happen on the main thread via [Handler].
*/
class Debouncer(
private val delayMs: Long,
private val block: (Int, Int) -> Unit,
) {
private val handler = Handler(Looper.getMainLooper())
private var pending: Runnable? = null
fun invoke(width: Float, height: Float) {
invoke(width.roundToInt(), height.roundToInt())
}
fun invoke(width: Int, height: Int) {
pending?.let { handler.removeCallbacks(it) }
val task = Runnable { block(width, height) }
pending = task
handler.postDelayed(task, delayMs)
}
fun cancel() {
pending?.let { handler.removeCallbacks(it) }
pending = null
}
}

View File

@@ -474,6 +474,7 @@
<string name="scrcpyopt_no_power_on">scrcpy 启动时不唤醒屏幕</string> <string name="scrcpyopt_no_power_on">scrcpy 启动时不唤醒屏幕</string>
<string name="scrcpyopt_power_off_on_close">scrcpy 结束后息屏</string> <string name="scrcpyopt_power_off_on_close">scrcpy 结束后息屏</string>
<string name="scrcpyopt_stay_awake">scrcpy 启动后保持设备唤醒状态(插入电源)</string> <string name="scrcpyopt_stay_awake">scrcpy 启动后保持设备唤醒状态(插入电源)</string>
<string name="scrcpyopt_keep_active">模拟用户活动防止息屏</string>
<string name="scrcpyopt_show_touches">显示物理触控</string> <string name="scrcpyopt_show_touches">显示物理触控</string>
<string name="scrcpyopt_fullscreen">启动后进入全屏</string> <string name="scrcpyopt_fullscreen">启动后进入全屏</string>
<string name="scrcpyopt_disable_screensaver">scrcpy 启动后保持本机屏幕常亮</string> <string name="scrcpyopt_disable_screensaver">scrcpy 启动后保持本机屏幕常亮</string>
@@ -485,6 +486,8 @@
<string name="scrcpyopt_video_bitrate">视频码率</string> <string name="scrcpyopt_video_bitrate">视频码率</string>
<string name="scrcpyopt_video_source">视频来源</string> <string name="scrcpyopt_video_source">视频来源</string>
<string name="scrcpyopt_display_id">监视器 ID</string> <string name="scrcpyopt_display_id">监视器 ID</string>
<string name="scrcpyopt_min_size_alignment">最小尺寸对齐</string>
<string name="scrcpyopt_min_size_alignment_invalid">对齐值必须是 2 的次方 (1, 2, 4, 8, 16)</string>
<string name="scrcpyopt_max_size">最大分辨率</string> <string name="scrcpyopt_max_size">最大分辨率</string>
<string name="scrcpyopt_max_fps">最大帧率</string> <string name="scrcpyopt_max_fps">最大帧率</string>
<string name="scrcpyopt_camera_id">摄像头 ID</string> <string name="scrcpyopt_camera_id">摄像头 ID</string>
@@ -492,6 +495,7 @@
<string name="scrcpyopt_camera_size">摄像头分辨率</string> <string name="scrcpyopt_camera_size">摄像头分辨率</string>
<string name="scrcpyopt_camera_fps">摄像头帧率</string> <string name="scrcpyopt_camera_fps">摄像头帧率</string>
<string name="scrcpyopt_camera_high_speed">高帧率模式</string> <string name="scrcpyopt_camera_high_speed">高帧率模式</string>
<string name="scrcpyopt_camera_torch">相机手电筒</string>
<string name="scrcpyopt_audio_source">音频来源</string> <string name="scrcpyopt_audio_source">音频来源</string>
<string name="scrcpyopt_audio_dup">音频双路输出</string> <string name="scrcpyopt_audio_dup">音频双路输出</string>
<string name="scrcpyopt_require_audio">音频转发失败时终止</string> <string name="scrcpyopt_require_audio">音频转发失败时终止</string>
@@ -509,6 +513,7 @@
<string name="scrcpyopt_no_mouse_hover">禁用鼠标悬停转发</string> <string name="scrcpyopt_no_mouse_hover">禁用鼠标悬停转发</string>
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string> <string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
<string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string> <string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
<string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string>
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string> <string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
<string name="scrcpyopt_native">本机</string> <string name="scrcpyopt_native">本机</string>
<string name="scrcpyopt_log_level">日志等级</string> <string name="scrcpyopt_log_level">日志等级</string>

View File

@@ -474,6 +474,7 @@
<string name="scrcpyopt_no_power_on">Do not power on the device on start</string> <string name="scrcpyopt_no_power_on">Do not power on the device on start</string>
<string name="scrcpyopt_power_off_on_close">Turn the device screen off when closing scrcpy</string> <string name="scrcpyopt_power_off_on_close">Turn the device screen off when closing scrcpy</string>
<string name="scrcpyopt_stay_awake">Keep the device on while scrcpy is running, when the device is plugged in</string> <string name="scrcpyopt_stay_awake">Keep the device on while scrcpy is running, when the device is plugged in</string>
<string name="scrcpyopt_keep_active">Simulate user activity to keep the screen on</string>
<string name="scrcpyopt_show_touches">Enable \"show touches\" on start, restore the initial value on exit</string> <string name="scrcpyopt_show_touches">Enable \"show touches\" on start, restore the initial value on exit</string>
<string name="scrcpyopt_fullscreen">Start in fullscreen</string> <string name="scrcpyopt_fullscreen">Start in fullscreen</string>
<string name="scrcpyopt_disable_screensaver">Disable screensaver while scrcpy is running</string> <string name="scrcpyopt_disable_screensaver">Disable screensaver while scrcpy is running</string>
@@ -485,6 +486,8 @@
<string name="scrcpyopt_video_bitrate">Video bitrate</string> <string name="scrcpyopt_video_bitrate">Video bitrate</string>
<string name="scrcpyopt_video_source">Video source</string> <string name="scrcpyopt_video_source">Video source</string>
<string name="scrcpyopt_display_id">Display ID</string> <string name="scrcpyopt_display_id">Display ID</string>
<string name="scrcpyopt_min_size_alignment">Min size alignment</string>
<string name="scrcpyopt_min_size_alignment_invalid">Alignment must be a power of 2 (1, 2, 4, 8, or 16)</string>
<string name="scrcpyopt_max_size">Max resolution</string> <string name="scrcpyopt_max_size">Max resolution</string>
<string name="scrcpyopt_max_fps">Max framerate</string> <string name="scrcpyopt_max_fps">Max framerate</string>
<string name="scrcpyopt_camera_id">Camera ID</string> <string name="scrcpyopt_camera_id">Camera ID</string>
@@ -492,6 +495,7 @@
<string name="scrcpyopt_camera_size">Camera resolution</string> <string name="scrcpyopt_camera_size">Camera resolution</string>
<string name="scrcpyopt_camera_fps">Camera framerate</string> <string name="scrcpyopt_camera_fps">Camera framerate</string>
<string name="scrcpyopt_camera_high_speed">High framerate mode</string> <string name="scrcpyopt_camera_high_speed">High framerate mode</string>
<string name="scrcpyopt_camera_torch">Camera torch</string>
<string name="scrcpyopt_audio_source">Audio source</string> <string name="scrcpyopt_audio_source">Audio source</string>
<string name="scrcpyopt_audio_dup">Duplicate audio (capture and keep playing on the device)</string> <string name="scrcpyopt_audio_dup">Duplicate audio (capture and keep playing on the device)</string>
<string name="scrcpyopt_require_audio">Terminate when audio forwarding fails</string> <string name="scrcpyopt_require_audio">Terminate when audio forwarding fails</string>
@@ -509,6 +513,7 @@
<string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string> <string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string>
<string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string> <string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string>
<string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation.</string> <string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation.</string>
<string name="scrcpyopt_flex_display">Automatically resize to match the screen</string>
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string> <string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
<string name="scrcpyopt_native">Native</string> <string name="scrcpyopt_native">Native</string>
<string name="scrcpyopt_log_level">Log level</string> <string name="scrcpyopt_log_level">Log level</string>