diff --git a/.gitignore b/.gitignore index be25ffe..26ed072 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ scrcpy.help* *.mp4 abandoned_assets/ + +# scrcpy-server binary (downloaded by Gradle during build) +app/src/main/assets/bin/scrcpy-server-v* diff --git a/TODO.md b/TODO.md index efbe94d..9f41972 100644 --- a/TODO.md +++ b/TODO.md @@ -4,45 +4,6 @@ - 新的原创图标而非从 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 - ~~orientation locking~~ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 898278f..f555116 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.net.URI +import java.security.MessageDigest import java.util.Properties plugins { @@ -56,8 +58,8 @@ android { applicationId = "io.github.miuzarte.scrcpyforandroid" minSdk = 26 targetSdk = 37 - versionCode = 30 - versionName = "0.3.4" + versionCode = 31 + versionName = "0.4.0" externalNativeBuild { cmake { @@ -162,3 +164,75 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) 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) +} diff --git a/app/src/main/assets/bin/.gitkeep b/app/src/main/assets/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/assets/bin/scrcpy-server-v3.3.4 b/app/src/main/assets/bin/scrcpy-server-v3.3.4 deleted file mode 100644 index 89054b7..0000000 Binary files a/app/src/main/assets/bin/scrcpy-server-v3.3.4 and /dev/null differ diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index a854cff..8dd7326 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -8,6 +8,11 @@ import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import kotlinx.coroutines.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.withLock import java.util.ArrayDeque @@ -22,6 +27,7 @@ import java.util.concurrent.CopyOnWriteArraySet */ object NativeCoreFacade { private val sessionLifecycleMutex = Mutex() + private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val renderer = PersistentVideoRenderer() private var activeSurfaceId: Int? = null private var decoder: AnnexBDecoder? = null @@ -41,6 +47,10 @@ object NativeCoreFacade { @Volatile 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() { sessionLifecycleMutex.withLock { releaseAllDecoders() @@ -78,6 +88,10 @@ object NativeCoreFacade { 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) } } @@ -157,8 +171,10 @@ object NativeCoreFacade { */ suspend fun onScrcpySessionStarted( session: Scrcpy.Session.SessionInfo, - sessionMgr: Scrcpy.Session + sessionMgr: Scrcpy.Session, + scrcpy: Scrcpy, ) = sessionLifecycleMutex.withLock { + scrcpyRef = scrcpy currentSessionInfo = session releaseAllDecoders() synchronized(bootstrapLock) { @@ -166,8 +182,13 @@ object NativeCoreFacade { latestConfigPacket = null } if (activeSurfaceId != null || recordingSurfaceAttached) { - Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") - createOrReplaceDecoder(session) + // v4.0: width/height come from first video session packet, not from initial metadata + 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 sessionMgr.attachVideoConsumer { packet -> @@ -179,9 +200,10 @@ object NativeCoreFacade { "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" ) } - val currentDecoder = decoder ?: return@attachVideoConsumer + + val dec = decoder ?: return@attachVideoConsumer runCatching { - currentDecoder.feedAnnexB( + dec.feedAnnexB( packet.data, packet.ptsUs, 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. * Cleans up decoders and resets state. @@ -201,6 +251,7 @@ object NativeCoreFacade { bootstrapPackets.clear() latestConfigPacket = null } + scrcpyRef = null currentSessionInfo = null recordingSurfaceAttached = false } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index 8738851..7532014 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -35,28 +35,8 @@ class Preset>(val values: List) { } } -/** - * Extension function for Int presets to find index from Int value. - */ -fun Preset.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.indexOfOrNearest(raw: String): Int { - if (raw.isBlank()) return 0 - val value = raw.toIntOrNull() ?: return 0 - return indexOfOrNearest(value) -} - 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 MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps val ScreenOffTimeout = Preset(listOf(0, 15, 30, 60, 120, 300, 600)) // sec diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt index f2a3628..8de1379 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabViewModel.kt @@ -605,9 +605,13 @@ internal class DeviceTabViewModel( val videoDetail = if (!resolvedOptions.video) "off" - else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" - else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + - "@%.1fMbps".format(activeBundle.videoBitRate / 1_000_000f) + else { + val codec = session.codec?.string ?: "null" + 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 = if (!activeBundle.audio) "off" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 80058f2..aa14fbd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -59,11 +59,12 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler 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.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.util.Debouncer import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry 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( modifier = Modifier .fillMaxSize() @@ -694,7 +709,17 @@ fun FullscreenControlPage( } 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 = if (session.height == 0) 16f / 9f diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 64901e2..883b1bd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -331,6 +331,7 @@ internal fun ScrcpyAllOptionsScreen( bundle = copySourceBundle, ) selectedProfileId = created.id + bindCurrentConnectedDevice(created.id) } ProfileDialogMode.Rename -> { @@ -597,6 +598,9 @@ internal fun ScrcpyAllOptionsPage( var cameraArInput by rememberSaveable(soBundle.cameraAr) { mutableStateOf(soBundle.cameraAr) } + var cameraZoomInput by rememberSaveable(soBundle.cameraZoom) { + mutableStateOf(soBundle.cameraZoom) + } val cameraFpsPresetIndex = rememberSaveable(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) { 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( title = stringResource(R.string.scrcpyopt_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( title = stringResource(R.string.scrcpyopt_max_size), summary = "--max-size", @@ -1433,6 +1484,20 @@ internal fun ScrcpyAllOptionsPage( .fillMaxWidth() .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( title = stringResource(R.string.scrcpyopt_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" + ) + }, + ) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index 8719cf4..78c0f4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -40,6 +40,8 @@ data class ClientOptions( var cameraSize: String = "", // to server // --camera-ar var cameraAr: String = "", // to server + // --camera-zoom + var cameraZoom: String = "", // to server // --camera-fps var cameraFps: UShort = 0u, // to server @@ -74,6 +76,9 @@ data class ClientOptions( // OR of enum sc_shortcut_mod values // var shortcutMods: ShortcutMod, + // --min-size-alignment + var minSizeAlignment: UByte = 1u, // to server + // --max-size var maxSize: UShort = 0u, // to server @@ -207,6 +212,15 @@ data class ClientOptions( var vdDestroyContent: Boolean = true, // to server // --no-vd-system-decorations 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) { 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 && displayId == 0 && newDisplay.isBlank() ) { @@ -542,6 +577,11 @@ data class ClientOptions( "Cannot start an Android app if control is disabled" ) } + if (keepActive) { + throw IllegalArgumentException( + "Cannot keep device active if control is disabled" + ) + } } return this @@ -560,6 +600,7 @@ data class ClientOptions( crop = crop, maxSize = maxSize, + minSizeAlignment = minSizeAlignment, videoBitRate = videoBitRate, audioBitRate = audioBitRate, maxFps = maxFps, @@ -583,6 +624,7 @@ data class ClientOptions( cameraId = cameraId, cameraSize = cameraSize, cameraAr = cameraAr, + cameraZoom = cameraZoom, cameraFps = cameraFps, powerOffOnClose = powerOffOnClose, @@ -595,8 +637,11 @@ data class ClientOptions( powerOn = powerOn, cameraHighSpeed = cameraHighSpeed, + cameraTorch = cameraTorch, vdDestroyContent = vdDestroyContent, vdSystemDecorations = vdSystemDecorations, + keepActive = keepActive, + flexDisplay = flexDisplay, list = list, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index c7e7938..53daf84 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -42,6 +42,8 @@ import java.io.DataOutputStream import java.io.EOFException import java.io.File import java.io.InputStreamReader +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.ArrayDeque import java.util.Locale import kotlin.concurrent.thread @@ -74,7 +76,10 @@ class Scrcpy( 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 clipboardSyncLock = Any() private val clipboardManager by lazy { @@ -99,6 +104,10 @@ class Scrcpy( @Volatile private var isRunning: Boolean = false + @Volatile + @JvmField + var flexDisplay: Boolean = false + @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null @@ -116,9 +125,9 @@ class Scrcpy( companion object { private const val TAG = "Scrcpy" - const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" - const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4" - const val DEFAULT_SERVER_VERSION = "3.3.4" + const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0" + const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0" + const val DEFAULT_SERVER_VERSION = "4.0" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" // Regex patterns for parsing server output @@ -197,11 +206,12 @@ class Scrcpy( forwardKeyRepeat = options.forwardKeyRepeat, ) isRunning = true + flexDisplay = options.flexDisplay startClipboardSync() // Setup video consumer (notify NativeCoreFacade to setup decoders) if (options.video) { - NativeCoreFacade.onScrcpySessionStarted(info, session) + NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy) } // Setup audio player @@ -287,7 +297,10 @@ class Scrcpy( Log.i( 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"}, " + "control=${options.control}" ) @@ -305,6 +318,7 @@ class Scrcpy( runCatching { aacRecorder?.release() } aacRecorder = null isRunning = false + flexDisplay = false _currentSessionState.value = null throw e } @@ -332,6 +346,7 @@ class Scrcpy( audioPlayer?.release() audioPlayer = null isRunning = false + flexDisplay = false _currentSessionState.value = null stopClipboardSync() Log.i(TAG, "stop(): Session stopped successfully") @@ -348,6 +363,10 @@ class Scrcpy( session.startApp(name) } + suspend fun resizeDisplay(width: Int, height: Int) = withContext(Dispatchers.IO) { + session.resizeDisplay(width, height) + } + suspend fun injectKeycode( action: Int, keycode: Int, @@ -423,6 +442,7 @@ class Scrcpy( val current = _currentSessionState.value ?: return if (current.width == width && current.height == height) return _currentSessionState.value = current.copy(width = width, height = height) + NativeCoreFacade.onVideoSizeChanged(width, height) } private fun startClipboardSync() { @@ -970,6 +990,7 @@ class Scrcpy( */ class Session( private val onRemoteClipboardText: (String) -> Unit, + private val onVideoSessionSize: (Int, Int) -> Unit, ) { private val mutex = Mutex() @@ -1085,20 +1106,17 @@ class Scrcpy( } else { 0 } - val videoCodecId: Int - val width: Int - val height: Int - if (options.video) { - val vInput = checkNotNull(videoInput) - videoCodecId = vInput.readInt() - width = vInput.readInt() - height = vInput.readInt() + val videoCodecId = if (options.video) { + checkNotNull(videoInput).readInt() } else { - videoCodecId = 0 - width = 0 - height = 0 + 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( deviceName = deviceName, codecId = videoCodecId, @@ -1154,11 +1172,37 @@ class Scrcpy( videoReaderThread = thread(start = true, name = "scrcpy-video-reader") { try { + val sessionFlagByte = (PACKET_FLAG_SESSION ushr 56).toInt() + val headerBuf = ByteArray(12) while (activeSession === session && !vStream.closed) { try { - val ptsAndFlags = vInput.readLong() - val packetSize = vInput.readInt() - if (packetSize <= 0) { + vInput.readFully(headerBuf) + + 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 } @@ -1244,7 +1288,14 @@ class Scrcpy( requireControlWriter().startApp(name) } catch (e: IllegalStateException) { 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) { val bytes = text.toByteArray(Charsets.UTF_8) output.writeByte(TYPE_SET_CLIPBOARD) - output.writeLong(CLIPBOARD_SEQUENCE_INVALID) + output.writeLong(SEQUENCE_INVALID) output.writeByte(if (paste) 1 else 0) output.writeInt(bytes.size) output.write(bytes) @@ -1698,6 +1749,36 @@ class Scrcpy( 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) { output.writeInt(x) output.writeInt(y) @@ -1725,9 +1806,10 @@ class Scrcpy( private const val CONNECT_RETRY_COUNT = 100 private const val CONNECT_RETRY_DELAY_MS = 100L private const val DEVICE_NAME_FIELD_LENGTH = 64 - private const val PACKET_FLAG_CONFIG = 1L shl 63 - private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 - private const val PACKET_PTS_MASK = (1L shl 62) - 1 + private const val PACKET_FLAG_SESSION = 1L shl 63 + private const val PACKET_FLAG_CONFIG = 1L shl 62 + 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_ERROR = 1 @@ -1741,10 +1823,29 @@ class Scrcpy( private const val TYPE_INJECT_TOUCH_EVENT = 2 private const val TYPE_INJECT_SCROLL_EVENT = 3 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_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 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 { return "scrcpy_%08x".format(scid) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index b79b5b2..f9897b5 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -14,84 +14,89 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource // 启动 scrcpy 前直接继承自 [ClientOptions], // 不需要默认值 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, - var audioCodec: Codec, - var videoSource: VideoSource, - var audioSource: AudioSource, + val videoCodec: Codec, + val audioCodec: Codec, + val videoSource: VideoSource, + val audioSource: AudioSource, - var cameraFacing: CameraFacing, + val cameraFacing: CameraFacing, - var crop: String, + val crop: String, - var videoCodecOptions: String, - var audioCodecOptions: String, - var videoEncoder: String, - var audioEncoder: String, + val videoCodecOptions: String, + val audioCodecOptions: String, + val videoEncoder: String, + val audioEncoder: String, - var cameraId: String, - var cameraSize: String, - var cameraAr: String, // aspect ratio - var cameraFps: UShort, + val cameraId: String, + val cameraSize: String, + val cameraAr: String, // aspect ratio + val cameraZoom: String, + val cameraFps: UShort, - // var portRange: PortRange, // sc_port_range(first, last) - // var tunnelHost: UInt, - // var tunnelPort: UShort, + // val portRange: PortRange, // sc_port_range(first, last) + // val tunnelHost: UInt, + // val tunnelPort: UShort, - var maxSize: UShort, + val maxSize: UShort, + val minSizeAlignment: UByte, - var videoBitRate: Int, - var audioBitRate: Int, + val videoBitRate: Int, + val audioBitRate: Int, - var maxFps: String, // float to be parsed by the server - var angle: String, // float to be parsed by the server + val maxFps: 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, - var captureOrientationLock: OrientationLock, + val captureOrientation: Orientation, + val captureOrientationLock: OrientationLock, - var control: Boolean, + val control: Boolean, - var displayId: Int, - var newDisplay: String, + val displayId: Int, + val newDisplay: String, - var displayImePolicy: DisplayImePolicy, + val displayImePolicy: DisplayImePolicy, - var video: Boolean, - var audio: Boolean, - var audioDup: Boolean, - var showTouches: Boolean, - var stayAwake: Boolean, + val video: Boolean, + val audio: Boolean, + val audioDup: Boolean, + val showTouches: Boolean, + val stayAwake: Boolean, - // var forceAdbForward: Boolean, + // val forceAdbForward: Boolean, - var powerOffOnClose: Boolean, - var legacyPaste: Boolean, - var clipboardAutosync: Boolean, + val powerOffOnClose: Boolean, + val legacyPaste: Boolean, + val clipboardAutosync: Boolean, - var downsizeOnError: Boolean, - // var tcpip: Boolean, - // var tcpipDst: String, - // var selectUsb: Boolean, - // var selectTcpip: Boolean, + val downsizeOnError: Boolean, + // val tcpip: Boolean, + // val tcpipDst: String, + // val selectUsb: Boolean, + // val selectTcpip: Boolean, - var cleanUp: Boolean, - var powerOn: Boolean, + val cleanUp: Boolean, + val powerOn: Boolean, - // var killAdbOnClose: Boolean, + // val killAdbOnClose: Boolean, - var cameraHighSpeed: Boolean, + val cameraHighSpeed: Boolean, + val cameraTorch: Boolean, - var vdDestroyContent: Boolean, - var vdSystemDecorations: Boolean, + val vdDestroyContent: Boolean, + val vdSystemDecorations: Boolean, + val keepActive: Boolean, + val flexDisplay: Boolean, - var list: ListOptions, + val list: ListOptions, ) { companion object { const val SEPARATOR: String = " " @@ -155,6 +160,9 @@ data class ServerParams( validate(maxFps) cmd.add("max_fps=${maxFps.trim()}") } + if (minSizeAlignment != 1.toUByte()) { + cmd.add("min_size_alignment=${minSizeAlignment}") + } if (angle.isNotBlank()) { validate(angle) cmd.add("angle=${angle.trim()}") @@ -207,6 +215,13 @@ data class ServerParams( if (cameraHighSpeed) { 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) { cmd.add("show_touches=true") } @@ -259,6 +274,9 @@ data class ServerParams( validate(newDisplay) cmd.add("new_display=${newDisplay.trim()}") } + if (flexDisplay) { + cmd.add("flex_display=true") + } if (displayImePolicy != DisplayImePolicy.UNDEFINED) { cmd.add("display_ime_policy=${displayImePolicy.string}") } @@ -268,6 +286,9 @@ data class ServerParams( if (!vdSystemDecorations) { cmd.add("vd_system_decorations=false") } + if (keepActive) { + cmd.add("keep_active=true") + } if (list has ListOptions.ENCODERS) { cmd.add("list_encoders=true") } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 4af691a..fbc6f1f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -24,6 +24,7 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking import kotlinx.parcelize.Parcelize +import org.json.JSONObject class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { companion object { @@ -75,6 +76,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { stringPreferencesKey("camera_ar"), "", ) + val CAMERA_ZOOM = Pair( + stringPreferencesKey("camera_zoom"), + "", + ) val CAMERA_FPS = Pair( intPreferencesKey("camera_fps"), 0, @@ -107,6 +112,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { stringPreferencesKey("camera_facing"), "any", ) + val MIN_SIZE_ALIGNMENT = Pair( + intPreferencesKey("min_size_alignment"), + 1, + ) val MAX_SIZE = Pair( intPreferencesKey("max_size"), 0, @@ -275,6 +284,18 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { booleanPreferencesKey("vd_system_decorations"), 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( crop = CROP.defaultValue, @@ -288,6 +309,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { cameraSizeCustom = CAMERA_SIZE_CUSTOM.defaultValue, cameraSizeUseCustom = CAMERA_SIZE_USE_CUSTOM.defaultValue, cameraAr = CAMERA_AR.defaultValue, + cameraZoom = CAMERA_ZOOM.defaultValue, cameraFps = CAMERA_FPS.defaultValue, logLevel = LOG_LEVEL.defaultValue, videoCodec = VIDEO_CODEC.defaultValue, @@ -296,6 +318,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { audioSource = AUDIO_SOURCE.defaultValue, recordFormat = RECORD_FORMAT.defaultValue, cameraFacing = CAMERA_FACING.defaultValue, + minSizeAlignment = MIN_SIZE_ALIGNMENT.defaultValue, maxSize = MAX_SIZE.defaultValue, videoBitRate = VIDEO_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, vdDestroyContent = VD_DESTROY_CONTENT.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 cameraSizeUseCustom: Boolean, val cameraAr: String, + val cameraZoom: String, val cameraFps: Int, val logLevel: String, val videoCodec: String, @@ -362,6 +389,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val audioSource: String, val recordFormat: String, val cameraFacing: String, + val minSizeAlignment: Int, val maxSize: Int, val videoBitRate: Int, val audioBitRate: Int, @@ -403,7 +431,10 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val startAppCustom: String, val startAppUseCustom: Boolean, val vdDestroyContent: Boolean, - val vdSystemDecorations: Boolean + val vdSystemDecorations: Boolean, + val cameraTorch: Boolean, + val keepActive: Boolean, + val flexDisplay: Boolean, ) : Parcelable { } @@ -419,6 +450,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { bundleField(CAMERA_SIZE_CUSTOM) { it.cameraSizeCustom }, bundleField(CAMERA_SIZE_USE_CUSTOM) { it.cameraSizeUseCustom }, bundleField(CAMERA_AR) { it.cameraAr }, + bundleField(CAMERA_ZOOM) { it.cameraZoom }, bundleField(CAMERA_FPS) { it.cameraFps }, bundleField(LOG_LEVEL) { it.logLevel }, bundleField(VIDEO_CODEC) { it.videoCodec }, @@ -427,6 +459,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { bundleField(AUDIO_SOURCE) { it.audioSource }, bundleField(RECORD_FORMAT) { it.recordFormat }, bundleField(CAMERA_FACING) { it.cameraFacing }, + bundleField(MIN_SIZE_ALIGNMENT) { it.minSizeAlignment }, bundleField(MAX_SIZE) { it.maxSize }, bundleField(VIDEO_BIT_RATE) { it.videoBitRate }, 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(VD_DESTROY_CONTENT) { it.vdDestroyContent }, bundleField(VD_SYSTEM_DECORATIONS) { it.vdSystemDecorations }, + bundleField(CAMERA_TORCH) { it.cameraTorch }, + bundleField(KEEP_ACTIVE) { it.keepActive }, + bundleField(FLEX_DISPLAY) { it.flexDisplay }, ) val bundleState: StateFlow = createBundleState(::bundleFromPreferences) @@ -484,6 +520,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { cameraSizeCustom = preferences.read(CAMERA_SIZE_CUSTOM), cameraSizeUseCustom = preferences.read(CAMERA_SIZE_USE_CUSTOM), cameraAr = preferences.read(CAMERA_AR), + cameraZoom = preferences.read(CAMERA_ZOOM), cameraFps = preferences.read(CAMERA_FPS), logLevel = preferences.read(LOG_LEVEL), videoCodec = preferences.read(VIDEO_CODEC), @@ -492,6 +529,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { audioSource = preferences.read(AUDIO_SOURCE), recordFormat = preferences.read(RECORD_FORMAT), cameraFacing = preferences.read(CAMERA_FACING), + minSizeAlignment = preferences.read(MIN_SIZE_ALIGNMENT), maxSize = preferences.read(MAX_SIZE), videoBitRate = preferences.read(VIDEO_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), vdDestroyContent = preferences.read(VD_DESTROY_CONTENT), 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) @@ -564,6 +605,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { cameraId = bundle.cameraId, cameraSize = if (!bundle.cameraSizeUseCustom) bundle.cameraSize else bundle.cameraSizeCustom, cameraAr = bundle.cameraAr, + cameraZoom = bundle.cameraZoom, cameraFps = bundle.cameraFps.toUShort(), logLevel = LogLevel.fromString(bundle.logLevel), videoCodec = Codec.fromString(bundle.videoCodec), @@ -572,6 +614,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { audioSource = AudioSource.fromString(bundle.audioSource), recordFormat = RecordFormat.fromString(bundle.recordFormat), cameraFacing = CameraFacing.fromString(bundle.cameraFacing), + minSizeAlignment = bundle.minSizeAlignment.toUByte(), maxSize = bundle.maxSize.toUShort(), videoBitRate = bundle.videoBitRate, audioBitRate = bundle.audioBitRate, @@ -611,6 +654,360 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { newDisplay = bundle.newDisplay, startApp = if (!bundle.startAppUseCustom) bundle.startApp else bundle.startAppCustom, 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 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt index 445aedc..50dcbe3 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt @@ -213,7 +213,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") { JSONObject() .put("id", profile.id) .put("name", profile.name) - .put("bundle", encodeBundle(profile.bundle)) + .put("bundle", encodeBundleToJson(profile.bundle)) ) } return array.toString() @@ -234,7 +234,7 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") { Profile( id = id, name = name.ifBlank { textNewProfile }, - bundle = decodeBundle(item.optJSONObject("bundle")), + bundle = decodeBundleFromJson(item.optJSONObject("bundle")), isBuiltinGlobal = id == ScrcpyOptions.GLOBAL_PROFILE_ID, ) ) @@ -242,331 +242,4 @@ class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") { } 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 -} +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/util/Debouncer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/util/Debouncer.kt new file mode 100644 index 0000000..bb3122b --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/util/Debouncer.kt @@ -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 + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 95b991f..7c19ba3 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -474,6 +474,7 @@ scrcpy 启动时不唤醒屏幕 scrcpy 结束后息屏 scrcpy 启动后保持设备唤醒状态(插入电源) + 模拟用户活动防止息屏 显示物理触控 启动后进入全屏 scrcpy 启动后保持本机屏幕常亮 @@ -485,6 +486,8 @@ 视频码率 视频来源 监视器 ID + 最小尺寸对齐 + 对齐值必须是 2 的次方 (1, 2, 4, 8, 16) 最大分辨率 最大帧率 摄像头 ID @@ -492,6 +495,7 @@ 摄像头分辨率 摄像头帧率 高帧率模式 + 相机手电筒 音频来源 音频双路输出 音频转发失败时终止 @@ -509,6 +513,7 @@ 禁用鼠标悬停转发 禁用结束后清理 默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作 + 自动调整尺寸以匹配界面 scrcpy 启动后打开应用 本机 日志等级 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ed3431..a3c2076 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -474,6 +474,7 @@ Do not power on the device on start Turn the device screen off when closing scrcpy Keep the device on while scrcpy is running, when the device is plugged in + Simulate user activity to keep the screen on Enable \"show touches\" on start, restore the initial value on exit Start in fullscreen Disable screensaver while scrcpy is running @@ -485,6 +486,8 @@ Video bitrate Video source Display ID + Min size alignment + Alignment must be a power of 2 (1, 2, 4, 8, or 16) Max resolution Max framerate Camera ID @@ -492,6 +495,7 @@ Camera resolution Camera framerate High framerate mode + Camera torch Audio source Duplicate audio (capture and keep playing on the device) Terminate when audio forwarding fails @@ -509,6 +513,7 @@ Do not forward mouse hover events Disable cleanup on exit 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. + Automatically resize to match the screen Open app after scrcpy starts Native Log level