Compare commits

...

10 Commits

Author SHA1 Message Date
4e07b71ee8 feat():build in local Android and run in honor pad. 2026-07-13 18:08:17 +08:00
謬紗特
d1e9b8cfdc upstream: scrcpy v4.1 (#59)
编译期下载 URL/版本/SHA256 更新至 v4.1
支持 VP8 与 VP9 解码
支持 ignoreVideoEncoderConstraints 参数
AnnexBDecoder.kt 重命名至 MediaCodecVideoDecoder.kt,避免在使用 vp8/vp9 时的语义问题
2026-07-13 13:53:46 +08:00
Miuzarte
d1e20c2a6b chore(deps): ignore miuix in renovatebot 2026-07-13 12:29:05 +08:00
Miuzarte
a2937f5fe1 chore: code formatting 2026-07-12 21:22:18 +08:00
Miuzarte
b918f18e9e fix: resolve MTK decoder crash on unsupported resolution and rotation
- 新增 DecoderCapabilities,创建 decoder 前预检本机解码器分辨率支持
2026-07-12 19:00:52 +08:00
Miuzarte
90a188f355 refactor: break down NativeCoreFacade
- 捕获解码器错误
- 旋转屏幕重建不复用旧 buffer
2026-07-12 18:40:54 +08:00
謬紗特
d459fae571 Merge pull request #51 from EX3124/main
fix: Status bar color when the application is opened
2026-07-10 13:09:50 +08:00
EX3124
30d6cb5191 fix: Status bar color when the application is opened 2026-07-10 12:52:09 +08:00
Miuzarte
14c95cd734 fix: make basicPip lazy to avoid NPE
close #50
2026-07-09 10:28:15 +08:00
Miuzarte
0fa3a3d544 feat: add BreadcrumbBar for dir path 2026-07-08 19:14:00 +08:00
32 changed files with 1097 additions and 317 deletions

2
.gitignore vendored
View File

@@ -47,3 +47,5 @@ abandoned_assets/
# scrcpy-server binary (downloaded by Gradle during build) # scrcpy-server binary (downloaded by Gradle during build)
app/src/main/assets/bin/scrcpy-server-v* app/src/main/assets/bin/scrcpy-server-v*
docs/

View File

@@ -1,9 +1,23 @@
# Change Log # Change Log
## 0.5.0
- 依赖: scrcpy 升级至 v4.1
- 新增: vp8/vp9 解码
## 0.4.5
- 重构: 改了好多不知道怎么写,总之修复了一些已知问题
## 0.4.4
- 修复: 画中画初始化空指针
## 0.4.3 ## 0.4.3
- 改进: 对控制通道增加流控选项 - 改进: 对控制通道增加流控选项
- 新增: 预发行版更新检测 - 新增: 预发行版更新检测
- 改进: 文件页的路径改用新实现的面包屑
## 0.4.2 ## 0.4.2

View File

@@ -29,7 +29,7 @@
<img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" /> <img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" />
<img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" /> <img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" />
<img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" /> <img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" />
<img src="https://github.com/user-attachments/assets/886ba6db-d6a7-4528-96fb-bb0ce4deb953" height="300" alt="Files" /> <img src="https://github.com/user-attachments/assets/3e4edc6c-ddfc-478b-aa7d-aa797a9f0e74" height="300" alt="Files" />
<img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" /> <img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" />
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" /> <img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" />
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" /> <img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" />

View File

@@ -59,8 +59,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 33 versionCode = 36
versionName = "0.4.3_pre1" versionName = "0.5.0"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -168,9 +168,9 @@ dependencies {
} }
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin" val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0" val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.1"
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0" val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-server-v4.1"
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a" val scrcpyServerSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
val downloadScrcpyServer by tasks.registering { val downloadScrcpyServer by tasks.registering {
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch" description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
@@ -203,7 +203,7 @@ val downloadScrcpyServer by tasks.registering {
val needsDownload = !file.exists() || computeSha256(file) != expectedSha val needsDownload = !file.exists() || computeSha256(file) != expectedSha
if (needsDownload) { if (needsDownload) {
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...") logger.lifecycle("Downloading scrcpy-server-v4.1 from GitHub releases...")
try { try {
URI(url).toURL().openStream().use { input -> URI(url).toURL().openStream().use { input ->
file.outputStream().use { output -> file.outputStream().use { output ->
@@ -212,7 +212,7 @@ val downloadScrcpyServer by tasks.registering {
} }
} catch (e: Exception) { } catch (e: Exception) {
throw GradleException( throw GradleException(
"Failed to download scrcpy-server-v4.0 from GitHub releases.\n" + "Failed to download scrcpy-server-v4.1 from GitHub releases.\n" +
" URL: $url\n" + " URL: $url\n" +
" You may download it manually and place it at: ${file.absolutePath}\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" + " If you are behind a proxy, check your Gradle proxy settings\n" +
@@ -223,14 +223,14 @@ val downloadScrcpyServer by tasks.registering {
val actualSha = computeSha256(file) val actualSha = computeSha256(file)
require(actualSha == expectedSha) { require(actualSha == expectedSha) {
"SHA256 mismatch for scrcpy-server-v4.0!\n" + "SHA256 mismatch for scrcpy-server-v4.1!\n" +
" Expected: $expectedSha\n" + " Expected: $expectedSha\n" +
" Got: $actualSha\n" + " Got: $actualSha\n" +
" Delete ${file.absolutePath} to retry download." " Delete ${file.absolutePath} to retry download."
} }
logger.lifecycle("scrcpy-server-v4.0 downloaded and verified.") logger.lifecycle("scrcpy-server-v4.1 downloaded and verified.")
} else { } else {
logger.lifecycle("scrcpy-server-v4.0 exists with correct SHA256, skip download.") logger.lifecycle("scrcpy-server-v4.1 exists with correct SHA256, skip download.")
} }
} }
} }

View File

@@ -1,58 +1,68 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.DecoderCapabilities
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.nativecore.VideoDecoderController
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.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch 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.concurrent.CopyOnWriteArraySet
/** /**
* Facade that centralizes video rendering. * Facade that centralizes video rendering and decoder management.
* *
* Provides helpers for: * Acts as a thin front-end over [VideoDecoderController] (decoder lifecycle, bootstrap
* - Surface/Decoder management for video rendering * cache, error detection) and [PersistentVideoRenderer] (EGL / surface management).
* - Video size and FPS monitoring *
* The facade owns the session lifecycle mutex and coordinates surface attach/detach
* with decoder creation. It also publishes video size / FPS to listeners.
*
* Error recovery:
* - When the local decoder cannot handle the video resolution (e.g. MTK AVC caps at
* 2048 but scrcpy sends 2400), the facade can automatically restart the session with
* a lower `max_size` (controlled by the "downsize on decode error" app setting).
* - When the decoder enters an unrecoverable error state at runtime (e.g. MTK OMX
* buffer conflict after rotation), the facade restarts the session.
*/ */
object NativeCoreFacade { object NativeCoreFacade {
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val renderer = PersistentVideoRenderer() private val renderer = PersistentVideoRenderer()
private val controller = VideoDecoderController(renderer)
@Volatile
private var activeSurfaceId: Int? = null private var activeSurfaceId: Int? = null
private var decoder: AnnexBDecoder? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile @Volatile
private var recordingSurfaceAttached = false private var recordingSurfaceAttached = false
@Volatile
private var latestConfigPacket: CachedPacket? = null
private var packetCount: Long = 0
@Volatile
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted) // Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
@Volatile @Volatile
private var scrcpyRef: Scrcpy? = null private var scrcpyRef: Scrcpy? = null
@Volatile
private var packetCount: Long = 0
// Cached ClientOptions for session restart (downgrade / error recovery)
@Volatile
private var cachedClientOptions: ClientOptions? = null
// Guards against recursive restarts (restart triggers onScrcpySessionStopped/Started)
@Volatile
private var isRestarting = false
suspend fun close() { suspend fun close() {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
releaseAllDecoders() controller.releaseAll()
renderer.release() renderer.release()
} }
} }
@@ -71,30 +81,20 @@ object NativeCoreFacade {
return return
} }
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)
if (activeSurfaceId == newId && decoder != null) { if (activeSurfaceId == newId && controller.isDecoderUsable()) {
return return
} }
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId") Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
activeSurfaceId = newId activeSurfaceId = newId
renderer.attachDisplaySurface(surface) controller.attachDisplaySurface(surface)
val session = currentSessionInfo ?: return val session = controller.getCurrentSessionInfo() ?: return
val currentDecoder = decoder if (controller.isDecoderUsable()) {
if (currentDecoder != null) {
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface") Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface()) if (controller.trySwitchDecoderSurface()) {
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
if (switched) {
return return
} }
} }
if (session.width <= 0 || session.height <= 0) { controller.ensureDecoder(session)
Log.i(
TAG,
"attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})",
)
return
}
createOrReplaceDecoder(session)
} }
} }
@@ -123,12 +123,7 @@ object NativeCoreFacade {
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder", "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
) )
activeSurfaceId = null activeSurfaceId = null
renderer.detachDisplaySurface(surface, releaseSurface = false) controller.detachDisplaySurface(surface, releaseDecoder)
if (releaseDecoder) {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release()
decoder = null
}
} }
} }
@@ -140,10 +135,10 @@ object NativeCoreFacade {
) { ) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
recordingSurfaceAttached = true recordingSurfaceAttached = true
renderer.attachRecordSurface(surface, width, height, onFrameRendered) controller.attachRecordSurface(surface, width, height, onFrameRendered)
val session = currentSessionInfo val session = controller.getCurrentSessionInfo()
if (session != null && decoder == null) { if (session != null && !controller.isDecoderUsable()) {
createOrReplaceDecoder(session) controller.ensureDecoder(session)
} }
} }
} }
@@ -154,94 +149,78 @@ object NativeCoreFacade {
) { ) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
recordingSurfaceAttached = false recordingSurfaceAttached = false
renderer.detachRecordSurface(surface, releaseSurface) controller.detachRecordSurface(surface, releaseSurface)
if (activeSurfaceId == null) { if (activeSurfaceId == null) {
decoder?.release() controller.releaseAll()
decoder = null
} }
} }
} }
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener) fun addVideoSizeListener(listener: (Int, Int) -> Unit) = controller.addVideoSizeListener(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener) fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = controller.removeVideoSizeListener(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener) fun addVideoFpsListener(listener: (Float) -> Unit) = controller.addVideoFpsListener(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener) fun removeVideoFpsListener(listener: (Float) -> Unit) = controller.removeVideoFpsListener(listener)
/** /**
* Called by Scrcpy.kt when a session starts. * Called by Scrcpy.kt when a session starts.
* Sets up video decoders for registered surfaces. * Sets up video decoders for registered surfaces.
*
* [options] is cached so the facade can restart the session (with a modified max_size)
* when the decoder fails.
*/ */
suspend fun onScrcpySessionStarted( suspend fun onScrcpySessionStarted(
session: Scrcpy.Session.SessionInfo, session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session, sessionMgr: Scrcpy.Session,
scrcpy: Scrcpy, scrcpy: Scrcpy,
options: ClientOptions,
) = sessionLifecycleMutex.withLock { ) = sessionLifecycleMutex.withLock {
scrcpyRef = scrcpy scrcpyRef = scrcpy
currentSessionInfo = session cachedClientOptions = options
releaseAllDecoders() isRestarting = false
synchronized(bootstrapLock) { controller.releaseAll()
bootstrapPackets.clear() controller.resetBootstrap()
latestConfigPacket = null
}
if (activeSurfaceId != null || recordingSurfaceAttached) { if (activeSurfaceId != null || recordingSurfaceAttached) {
// v4.0: width/height come from first video session packet, not from initial metadata // v4.0: width/height come from first video session packet, not from initial metadata
if (session.width > 0 && session.height > 0) { if (session.width > 0 && session.height > 0) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session) controller.ensureDecoder(session)
} else { } else {
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)") Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
} }
} }
packetCount = 0 packetCount = 0
sessionMgr.attachVideoConsumer { packet -> sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet) cacheAndFeed(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}",
)
}
val dec = decoder ?: return@attachVideoConsumer
runCatching {
dec.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig,
)
}
} }
} }
/** /**
* Called by Scrcpy when a video session packet arrives with new dimensions. * 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. * Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
*
* Before creating the decoder, checks whether the local hardware decoder supports the
* target resolution. If not, triggers a downgrade restart (when enabled) or shows an
* error snackbar.
*/ */
fun onVideoSizeChanged(width: Int, height: Int) { fun onVideoSizeChanged(width: Int, height: Int) {
lifecycleScope.launch { lifecycleScope.launch {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
if (isRestarting) return@withLock
val scrcpy = scrcpyRef ?: return@withLock val scrcpy = scrcpyRef ?: return@withLock
val info = scrcpy.currentSessionState.value ?: return@withLock val info = scrcpy.currentSessionState.value ?: return@withLock
if (info.width <= 0 || info.height <= 0) return@withLock if (info.width <= 0 || info.height <= 0) return@withLock
if (decoder == null) { val mime = info.codec?.mime ?: "video/avc"
// Initial creation (v4.0: deferred until first session packet) if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}") Log.w(
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, TAG,
"onVideoSizeChanged(): rebuild decoder ${currentSessionInfo!!.width}x${currentSessionInfo!!.height} ${info.width}x${info.height}", "onVideoSizeChanged(): decoder does not support ${info.width}x${info.height} for $mime",
) )
currentSessionInfo = info handleUnsupportedSize(info.width, info.height, mime)
createOrReplaceDecoder(info) return@withLock
} }
controller.rebuildDecoderForSize(info)
} }
} }
} }
@@ -251,150 +230,98 @@ object NativeCoreFacade {
* Cleans up decoders and resets state. * Cleans up decoders and resets state.
*/ */
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
releaseAllDecoders() controller.releaseAll()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
scrcpyRef = null scrcpyRef = null
currentSessionInfo = null
recordingSurfaceAttached = false recordingSurfaceAttached = false
} }
private const val TAG = "NativeCoreFacade" private fun cacheAndFeed(packet: Scrcpy.Session.VideoPacket) {
private const val MAX_BOOTSTRAP_PACKETS = 90 packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
private data class CachedPacket( Log.i(
val data: ByteArray, TAG,
val ptsUs: Long, "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} usable=${controller.isDecoderUsable()}",
val isConfig: Boolean, )
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CachedPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
} }
val usable = controller.feed(packet)
override fun hashCode(): Int { if (!usable && !isRestarting) {
var result = ptsUs.hashCode() Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
result = 31 * result + isConfig.hashCode() handleDecoderError()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
} }
} }
/** /**
* Create or replace the active decoder bound to [surface] for [session]. * Handle the case where the local decoder does not support the target resolution.
* *
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder]. * When "downsize on decode error" is enabled (default), restarts the session with a
* - The decoder's `onOutputSizeChanged` callback publishes size changes to * lower `max_size` derived from [DecoderCapabilities.maxSupportedSize]. Otherwise,
* registered listeners on the main thread. * shows an error snackbar and leaves the decoder uncreated (black screen, no crash).
* - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup.
*/ */
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) { private suspend fun handleUnsupportedSize(width: Int, height: Int, mime: String) {
val surface = renderer.getDecoderSurface() val downsizeEnabled = Storage.appSettings.bundleState.value.downsizeOnDecodeError
decoder?.release() if (!downsizeEnabled) {
decoder = null AppRuntime.snackbar(R.string.vm_decoder_init_failed, "${width}x${height}")
Log.i(
TAG,
"createOrReplaceDecoder(): " +
"codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"persistent=true",
)
val newDecoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = when (session.codec) {
Codec.H264 -> "video/avc"
Codec.H265 -> "video/hevc"
Codec.AV1 -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
return@AnnexBDecoder
}
Log.i(
TAG,
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
)
currentSessionInfo = current.copy(width = width, height = height)
mainHandler.post {
videoSizeListeners.forEach { listener ->
runCatching { listener(width, height) }
}
}
},
onFpsUpdated = { fps ->
mainHandler.post {
videoFpsListeners.forEach { listener ->
runCatching { listener(fps) }
}
}
},
)
decoder = newDecoder
replayBootstrapPackets(newDecoder)
}
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
if (snapshot.isEmpty()) {
return return
} }
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}") val maxSize = DecoderCapabilities.maxSupportedSize(mime)
snapshot.forEach { packet -> Log.i(TAG, "handleUnsupportedSize(): downgrading to max_size=$maxSize")
runCatching { AppRuntime.snackbar(R.string.vm_decoder_unsupported_size, width, height, maxSize)
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig) downgradeAndRestart(maxSize)
}
/**
* Handle a runtime decoder error (codec entered unrecoverable state).
*
* Restarts the scrcpy session so a fresh decoder is created from scratch.
*/
private fun handleDecoderError() {
if (isRestarting) return
isRestarting = true
Log.i(TAG, "handleDecoderError(): restarting session due to decoder error")
AppRuntime.snackbar(R.string.vm_decoder_error_restarting)
lifecycleScope.launch {
sessionLifecycleMutex.withLock {
restartSessionWith(cachedClientOptions)
} }
} }
} }
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) { /**
val cached = CachedPacket( * Restart the scrcpy session with a modified `max_size`.
data = packet.data.copyOf(), *
ptsUs = packet.ptsUs, * Persists the new max_size to [Storage.scrcpyOptions] so the user sees the change
isConfig = packet.isConfig, * in the UI, then stops and restarts the session.
isKeyFrame = packet.isKeyFrame, */
) private suspend fun downgradeAndRestart(maxSize: Int) {
synchronized(bootstrapLock) { if (isRestarting) return
if (cached.isConfig) { isRestarting = true
latestConfigPacket = cached val options = cachedClientOptions ?: return
bootstrapPackets.clear() val newOptions = options.copy(maxSize = maxSize.toUShort()).fix().validate()
bootstrapPackets.addLast(cached)
return
}
if (cached.isKeyFrame) { // Persist the new max_size so the UI reflects the downgrade.
bootstrapPackets.clear() runCatching {
latestConfigPacket?.let { bootstrapPackets.addLast(it) } val soBundle = Storage.scrcpyOptions.bundleState.value
bootstrapPackets.addLast(cached) Storage.scrcpyOptions.saveBundle(soBundle.copy(maxSize = maxSize))
return
}
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
bootstrapPackets.removeFirst()
}
bootstrapPackets.addLast(cached)
} }
restartSessionWith(newOptions)
} }
private fun releaseAllDecoders() { /**
runCatching { decoder?.release() } * Stop the current session and start a new one with [options].
decoder = null *
* Must be called while holding [sessionLifecycleMutex]. The [isRestarting] flag
* prevents recursive restarts — it is cleared in [onScrcpySessionStarted] when the
* new session comes up.
*/
private suspend fun restartSessionWith(options: ClientOptions?) {
val scrcpy = scrcpyRef ?: return
if (options == null) return
Log.i(TAG, "restartSessionWith(): stopping current session")
runCatching { scrcpy.stop(Scrcpy.StopReason.USER) }
Log.i(TAG, "restartSessionWith(): starting new session with max_size=${options.maxSize}")
runCatching { scrcpy.start(options) }
} }
private const val TAG = "NativeCoreFacade"
} }

View File

@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
class StreamActivity: FragmentActivity() { class StreamActivity: FragmentActivity() {
private val basicPip = BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
private val pipActionReceiver = PictureInPictureActionReceiver() private val pipActionReceiver = PictureInPictureActionReceiver()
private var isPipActionReceiverRegistered = false private var isPipActionReceiverRegistered = false

View File

@@ -140,6 +140,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
val p = port ?: old.port val p = port ?: old.port
listOf("$host:$p") listOf("$host:$p")
} }
updateById -> old.addresses updateById -> old.addresses
newPort != null -> { newPort != null -> {

View File

@@ -0,0 +1,92 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.util.Log
/**
* Queries the local device's hardware decoder capabilities to determine whether a given
* video size is supported before attempting to create a [MediaCodec].
*
* This is primarily needed for chips like MTK AC8257 whose AVC decoder caps at 2048x1088,
* while scrcpy may send 1080x2400 (long edge 2400 > 2048), causing
* [MediaCodec.configure] to throw [IllegalArgumentException].
*/
object DecoderCapabilities {
private const val TAG = "DecoderCapabilities"
/**
* The conservative fallback max size returned by [maxSupportedSize] when the codec list
* query fails or returns no usable information. 1920 is a safe upper bound for virtually
* all hardware decoders on Android 10+.
*/
private const val FALLBACK_MAX_SIZE = 1920
/**
* Check whether any hardware decoder on this device supports decoding [width]x[height]
* for the given [mime] type.
*
* - Only decoders (not encoders) are considered.
* - Software-only decoders are included as a last resort (some devices only have a
* software decoder for AV1), but hardware decoders are preferred.
* - When [MediaCodecInfo.VideoCapabilities.areSizeSupported] is unavailable or throws,
* returns true (optimistic) so the caller can still attempt creation and rely on
* [DecoderException] for the error path.
*/
fun isSizeSupported(mime: String, width: Int, height: Int): Boolean {
if (width <= 0 || height <= 0) return true
return try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
codecList.codecInfos.any { info ->
info.isEncoder.not() &&
isCodecCapable(info, mime, width, height)
}
} catch (e: Exception) {
Log.w(TAG, "isSizeSupported(): query failed for $mime ${width}x$height, assuming supported", e)
true
}
}
/**
* Returns the largest "long edge" size (i.e. `max(width, height)`) that any decoder on
* this device supports for [mime].
*
* Used by the downgrade logic to pick a new `max_size` value that the decoder can handle.
* The probe tries common sizes from 1080 up to 4096; if none are supported (unlikely),
* returns [FALLBACK_MAX_SIZE].
*/
fun maxSupportedSize(mime: String): Int {
return try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val decoders = codecList.codecInfos.filter { !it.isEncoder }
// Try from largest to smallest; return the first that works for any decoder.
// This avoids probing every integer — a handful of common breakpoints is enough.
for (size in intArrayOf(3840, 2560, 2048, 1920, 1600, 1280, 1080)) {
if (decoders.any { isCodecCapable(it, mime, size, size) }) {
return size
}
}
FALLBACK_MAX_SIZE
} catch (e: Exception) {
Log.w(TAG, "maxSupportedSize(): query failed for $mime, returning fallback $FALLBACK_MAX_SIZE", e)
FALLBACK_MAX_SIZE
}
}
private fun isCodecCapable(
info: MediaCodecInfo,
mime: String,
width: Int,
height: Int,
): Boolean {
return try {
val caps = info.getCapabilitiesForType(mime) ?: return false
caps.videoCapabilities?.isSizeSupported(width, height) == true
} catch (e: Exception) {
// Some codecs throw for unsupported mime types — that's fine, just skip.
false
}
}
}

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
/**
* Thrown when a [MediaCodec] decoder fails to initialize or enters an unrecoverable error state.
*
* Carries the MIME type and target dimensions so callers (e.g. [VideoDecoderController])
* can decide whether to downgrade the video size and restart the session.
*/
class DecoderException(
val mime: String,
val width: Int,
val height: Int,
cause: Throwable? = null,
): RuntimeException(
"Decoder failed for mime=$mime size=${width}x${height}",
cause,
)

View File

@@ -308,8 +308,7 @@ internal object DirectAdbTransport {
private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey { private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey {
val kf = KeyFactory.getInstance("RSA") val kf = KeyFactory.getInstance("RSA")
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull() val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
return if (key is RSAPrivateCrtKey) key return key as? RSAPrivateCrtKey ?: kf.generatePrivate(parsePkcs8PrivateKey(der))
else kf.generatePrivate(parsePkcs8PrivateKey(der))
} }
private fun parsePublicKey(content: String): PublicKey { private fun parsePublicKey(content: String): PublicKey {
@@ -366,7 +365,25 @@ internal object DirectAdbTransport {
private data class PemBlock( private data class PemBlock(
val label: String, val label: String,
val bytes: ByteArray, val bytes: ByteArray,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PemBlock
if (label != other.label) return false
if (!bytes.contentEquals(other.bytes)) return false
return true
}
override fun hashCode(): Int {
var result = label.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}
}
private fun readPem(content: String): PemBlock? { private fun readPem(content: String): PemBlock? {
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
@@ -661,9 +678,13 @@ internal class DirectAdbConnection(
fun openStream(service: String): AdbSocketStream { fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement() val localId = nextLocalId.getAndIncrement()
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
val stream = AdbSocketStream(localId, { command, arg0, arg1, data -> val stream = AdbSocketStream(
sendMsg(command, arg0, arg1, data) localId,
}, flowControlWindow) { command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data)
},
flowControlWindow,
)
streams[localId] = stream streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8)) sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try { try {
@@ -979,8 +1000,9 @@ class AdbSocketStream(
private val queue = LinkedBlockingQueue<Any>() private val queue = LinkedBlockingQueue<Any>()
// need notifyAll() / wait() // need notifyAll() / wait()
private val writeLock = java.lang.Object() private val writeLock = Object()
@Volatile private var inflightWrites = 0 @Volatile
private var inflightWrites = 0
private object EndOfStreamMarker private object EndOfStreamMarker

View File

@@ -6,19 +6,21 @@ import android.util.Log
import android.view.Surface import android.view.Surface
/** /**
* AnnexBDecoder * MediaCodecVideoDecoder
* *
* Purpose: * Purpose:
* - Wraps Android MediaCodec for Annex-B framed codecs (H.264/H.265/AV1). * - Wraps Android MediaCodec for all video codecs (H.264/H.265/AV1/VP8/VP9).
* - Handles critical startup packets (config/keyframes) and provides callbacks * - Handles critical startup packets (config/keyframes) and provides callbacks
* for output size changes and FPS updates. * for output size changes and FPS updates.
* - Codecs without Annex-B config packets (VP8/VP9) work natively: no CSD is
* required for MediaCodec initialization.
* *
* Threading / safety: * Threading / safety:
* - Public methods are synchronized to allow calls from multiple threads * - Public methods are synchronized to allow calls from multiple threads
* (packet producer vs. teardown). Internally, MediaCodec callbacks and * (packet producer vs. teardown). Internally, MediaCodec callbacks and
* buffer queues are used on the calling thread. * buffer queues are used on the calling thread.
*/ */
class AnnexBDecoder( class MediaCodecVideoDecoder(
width: Int, width: Int,
height: Int, height: Int,
outputSurface: Surface, outputSurface: Surface,
@@ -41,9 +43,23 @@ class AnnexBDecoder(
@Volatile @Volatile
private var released = false private var released = false
/**
* Set when the codec reports an unrecoverable error (either during configure/start
* or at runtime via [MediaCodec.CodecException] / [IllegalStateException]).
*
* Once non-null, [isUsable] returns false and subsequent [feedAnnexB] calls are no-ops.
* The controller ([VideoDecoderController]) checks this to trigger session restart.
*/
@Volatile
internal var codecError: Throwable? = null
private set
init { init {
if (!outputSurface.isValid) { if (!outputSurface.isValid) {
throw IllegalStateException("Cannot initialize decoder: output surface is not valid") throw DecoderException(
mimeType, width, height,
IllegalStateException("Output surface is not valid"),
)
} }
val format = MediaFormat.createVideoFormat(mimeType, width, height) val format = MediaFormat.createVideoFormat(mimeType, width, height)
if (sps != null) { if (sps != null) {
@@ -52,8 +68,14 @@ class AnnexBDecoder(
if (pps != null) { if (pps != null) {
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps)) format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
} }
codec.configure(format, outputSurface, null, 0) try {
codec.start() codec.configure(format, outputSurface, null, 0)
codec.start()
} catch (e: Exception) {
runCatching { codec.release() }
codecError = e
throw DecoderException(mimeType, width, height, e)
}
} }
/** /**
@@ -68,7 +90,7 @@ class AnnexBDecoder(
*/ */
@Synchronized @Synchronized
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) { fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
if (released) { if (released || codecError != null) {
return return
} }
runCatching { runCatching {
@@ -107,9 +129,13 @@ class AnnexBDecoder(
} }
drainOutput() drainOutput()
}.onFailure { }.onFailure {
Log.w( // MediaCodec.CodecException and IllegalStateException indicate the codec is in an
// unrecoverable error state (e.g. MTK OMX fatal error after surface conflicts).
// Record the error so isUsable() returns false and the controller can react.
codecError = it
Log.e(
TAG, TAG,
"feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig", "feed failed (codec marked unusable): mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
it, it,
) )
} }
@@ -145,6 +171,12 @@ class AnnexBDecoder(
runCatching { codec.release() } runCatching { codec.release() }
} }
/**
* Returns true when the decoder is still alive and the codec has not reported
* an unrecoverable error.
*/
fun isUsable(): Boolean = !released && codecError == null
private fun drainOutput() { private fun drainOutput() {
while (true) { while (true) {
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US) val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
@@ -202,7 +234,7 @@ class AnnexBDecoder(
} }
companion object { companion object {
private const val TAG = "AnnexBDecoder" private const val TAG = "MediaCodecVideoDecoder"
private const val MIME_AVC = "video/avc" private const val MIME_AVC = "video/avc"
private const val INPUT_TIMEOUT_US = 10_000L private const val INPUT_TIMEOUT_US = 10_000L
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L

View File

@@ -62,6 +62,75 @@ class PersistentVideoRenderer {
} }
} }
/**
* Atomically tear down and recreate the decoder-side surface (SurfaceTexture + OES texture
* + Surface), returning a fresh [Surface] for a new MediaCodec to render into.
*
* This is needed because some OMX decoders (notably MTK) fail when a new codec is configured
* against a surface that still has pending buffers from a previously released codec.
* By recreating the underlying SurfaceTexture + texture, the new codec gets a clean buffer
* queue with no leftover state.
*
* - Must be called after the old decoder has been released, so no producer is writing to
* the old surface at the same time.
* - Runs on the renderer HandlerThread (via [handler]) so it is serialized with [drawFrame].
* - EGL context, display/record EGL surfaces, and the shader program are preserved; only
* `oesTextureId` / `decoderSurfaceTexture` / `decoderSurface` are recreated.
*/
fun recreateDecoderSurface(): Surface {
ensureInitialized()
val latch = java.util.concurrent.CountDownLatch(1)
var result: Surface? = null
handler.post {
try {
if (released) {
error("renderer already released")
}
recreateDecoderSurfaceLocked()
result = decoderSurface
} finally {
latch.countDown()
}
}
latch.await()
return result ?: error("failed to recreate decoder surface")
}
private fun recreateDecoderSurfaceLocked() {
Log.i(tag, "recreateDecoderSurfaceLocked(): rebuilding decoder surface")
// Tear down old decoder-side resources. drawFrame() won't run concurrently
// because we're on the same HandlerThread.
runCatching { decoderSurface?.release() }
decoderSurface = null
runCatching { decoderSurfaceTexture?.release() }
decoderSurfaceTexture = null
if (oesTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
oesTextureId = 0
}
// Recreate from scratch (mirrors initializeLocked lines 243-259)
oesTextureId = createExternalTexture()
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
setOnFrameAvailableListener(
{
val n = frameAvailableCount.incrementAndGet()
if (n == 1L || n % 120L == 0L) {
Log.i(
tag,
"onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}",
)
}
drawFrame()
},
handler,
)
}
decoderSurface = Surface(decoderSurfaceTexture)
Log.i(tag, "recreateDecoderSurfaceLocked(): decoder surface rebuilt, texture=$oesTextureId")
}
fun attachDisplaySurface(surface: Surface) { fun attachDisplaySurface(surface: Surface) {
ensureInitialized() ensureInitialized()
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)

View File

@@ -0,0 +1,417 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.util.ArrayDeque
import java.util.concurrent.CopyOnWriteArraySet
/**
* Owns the video [MediaCodecVideoDecoder] lifecycle and all state surrounding it:
*
* - Creating / rebuilding / releasing the decoder bound to the persistent renderer surface.
* - Caching and replaying bootstrap packets (config + keyframe + following frames) so a
* freshly created decoder can resume playback quickly.
* - Detecting decoder errors (both construction-time [DecoderException] and runtime
* codec errors) and exposing them via [isDecoderUsable] / the return value of [feed]
* for the facade to react.
* - Publishing video size / FPS changes to registered listeners on the main thread.
*
* Threading:
* - All public methods are safe to call from any thread; mutations are guarded by
* [controllerLock] for synchronous methods and by the facade's session mutex for
* suspend methods.
* - The decoder itself is single-threaded (MediaCodecVideoDecoder is @Synchronized).
*
* This class does **not** decide session restart policy; it reports errors and lets
* [NativeCoreFacade] decide whether to restart the scrcpy session.
*/
internal class VideoDecoderController(
private val renderer: PersistentVideoRenderer,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val controllerLock = Any()
private var decoder: MediaCodecVideoDecoder? = null
@Volatile
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile
private var latestConfigPacket: CachedPacket? = null
/**
* The session info currently associated with the decoder (or the most recently released
* one). Returns null if no session has ever been bound.
*
* The facade uses this instead of maintaining its own copy, so there is a single source
* of truth kept in sync by [ensureDecoder] / [rebuildDecoderForSize] / the
* `onOutputSizeChanged` callback.
*/
fun getCurrentSessionInfo(): Scrcpy.Session.SessionInfo? = currentSessionInfo
// ---------- Listeners ----------
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener)
// ---------- Session lifecycle ----------
/**
* Tear down everything: release the decoder and drop bootstrap state.
* Called by the facade when a scrcpy session stops or when the controller is closed.
*/
fun releaseAll() {
synchronized(controllerLock) {
runCatching { decoder?.release() }
decoder = null
currentSessionInfo = null
}
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
}
/**
* Reset bootstrap state without touching the decoder (used on session start before a new
* decoder is created).
*/
fun resetBootstrap() {
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
}
// ---------- Surface management ----------
/**
* Try to switch the active decoder's output surface to the renderer's current decoder
* surface. Returns true if the switch succeeded (or was unnecessary), false if the
* caller needs to recreate the decoder.
*/
fun trySwitchDecoderSurface(): Boolean {
synchronized(controllerLock) {
val current = decoder ?: return false
val switched = current.switchOutputSurface(renderer.getDecoderSurface())
Log.i(TAG, "trySwitchDecoderSurface(): success=$switched")
return switched
}
}
/**
* Attach the display surface to the renderer. Does not touch the decoder directly.
*/
fun attachDisplaySurface(surface: Surface) {
renderer.attachDisplaySurface(surface)
}
/**
* Detach the display surface. When [releaseDecoder] is true the decoder is also released
* because the backing surface is being destroyed.
*/
fun detachDisplaySurface(surface: Surface?, releaseDecoder: Boolean) {
renderer.detachDisplaySurface(surface, releaseSurface = false)
if (releaseDecoder) {
synchronized(controllerLock) {
Log.i(TAG, "detachDisplaySurface(): releasing decoder with destroyed surface")
runCatching { decoder?.release() }
decoder = null
}
}
}
/**
* Attach a recording surface to the renderer. If no decoder exists yet but session info
* is known, a new decoder is created.
*/
fun attachRecordSurface(
surface: Surface,
width: Int,
height: Int,
onFrameRendered: ((Long) -> Unit)?,
) {
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
}
fun detachRecordSurface(surface: Surface?, releaseSurface: Boolean) {
renderer.detachRecordSurface(surface, releaseSurface)
}
// ---------- Decoder creation / rebuild ----------
/**
* Create a decoder for [session] if none exists. Should be called when a display or
* recording surface is attached and the session size is known.
*
* Returns true if a decoder exists after the call (either pre-existing or newly created).
*/
fun ensureDecoder(session: Scrcpy.Session.SessionInfo): Boolean {
synchronized(controllerLock) {
val current = decoder
if (current != null && current.isUsable()) return true
if (session.width <= 0 || session.height <= 0) {
Log.i(
TAG,
"ensureDecoder(): defer decoder, session size not yet known (${session.width}x${session.height})",
)
return false
}
// If a damaged decoder exists, release it first.
if (current != null) {
Log.i(TAG, "ensureDecoder(): releasing damaged decoder before rebuild")
runCatching { current.release() }
decoder = null
}
currentSessionInfo = session
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
}
/**
* Rebuild the decoder when the video size changes (e.g. flex-display rotation).
*
* - Recreates the persistent decoder surface to avoid OMX buffer conflicts on chips
* like MTK that fail when a new codec is configured against a surface with leftover
* buffers from a previously released codec.
* - Caches the new session info so future size-change checks can compare.
*
* Returns true if the decoder was rebuilt, false if skipped (same size, no decoder, etc.).
*/
suspend fun rebuildDecoderForSize(session: Scrcpy.Session.SessionInfo): Boolean {
val current = synchronized(controllerLock) { currentSessionInfo }
if (current != null && current.width == session.width && current.height == session.height) {
return false
}
val needRecreateSurface: Boolean
synchronized(controllerLock) {
if (decoder == null) {
// Initial creation (deferred until first session packet).
Log.i(TAG, "rebuildDecoderForSize(): create decoder ${session.width}x${session.height}")
currentSessionInfo = session
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
// Release the old decoder now so OMX stops writing to the surface, then
// drop the lock for the blocking delay + surface recreation below.
Log.i(
TAG,
"rebuildDecoderForSize(): rebuild decoder ${current?.width}x${current?.height} -> ${session.width}x${session.height}",
)
currentSessionInfo = session
runCatching { decoder?.release() }
decoder = null
needRecreateSurface = true
}
// Give OMX time to return buffers to the old native window before we tear down
// the SurfaceTexture, then recreate the surface. Both block, so run on IO.
//
// No lock is held here: the old decoder is already released and `decoder` is null,
// so feed() will no-op. The facade's sessionLifecycleMutex ensures no other
// lifecycle operation runs concurrently.
withContext(Dispatchers.IO) {
delay(50)
runCatching { renderer.recreateDecoderSurface() }
}
synchronized(controllerLock) {
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
}
/**
* Core method: construct a new [MediaCodecVideoDecoder] and replay bootstrap packets into it.
*
* Caller must have already released the old decoder (if any) and, when needed, recreated
* the persistent surface. Must be called while holding [controllerLock].
*/
private fun createOrReplaceDecoderLocked(
session: Scrcpy.Session.SessionInfo,
recreateSurface: Boolean,
) {
if (recreateSurface) {
// Should not happen in the current call path — surface recreation is handled
// by rebuildDecoderForSize outside the lock. Keep the parameter for clarity.
error("recreateSurface must be handled by caller")
}
val surface = renderer.getDecoderSurface()
val mime = session.codec?.mime ?: "video/avc"
Log.i(
TAG,
"createOrReplaceDecoder(): codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"recreateSurface=$recreateSurface, " +
"persistent=true",
)
val newDecoder = try {
MediaCodecVideoDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = mime,
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
return@MediaCodecVideoDecoder
}
Log.i(
TAG,
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
)
currentSessionInfo = current.copy(width = width, height = height)
mainHandler.post {
videoSizeListeners.forEach { listener ->
runCatching { listener(width, height) }
}
}
},
onFpsUpdated = { fps ->
mainHandler.post {
videoFpsListeners.forEach { listener ->
runCatching { listener(fps) }
}
}
},
)
} catch (e: DecoderException) {
Log.e(TAG, "createOrReplaceDecoder(): decoder init failed for ${e.mime} ${e.width}x${e.height}", e)
// Surface already exists; leave decoder null. The facade will see isUsable()==false
// and can trigger downgrade/restart.
return
}
decoder = newDecoder
replayBootstrapPackets(newDecoder)
}
// ---------- Packet feeding ----------
/**
* Cache a bootstrap packet and feed it to the active decoder if one exists.
*
* Returns true if the decoder is still usable after feeding, false if the decoder
* entered an error state (the facade should restart the session in that case).
*/
fun feed(packet: Scrcpy.Session.VideoPacket): Boolean {
cacheBootstrapPacket(packet)
val dec = synchronized(controllerLock) { decoder } ?: return true
runCatching {
dec.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig,
)
}
return dec.isUsable()
}
/**
* Whether the current decoder (if any) is still usable.
*/
fun isDecoderUsable(): Boolean {
val dec = synchronized(controllerLock) { decoder } ?: return false
return dec.isUsable()
}
// ---------- Bootstrap packet cache ----------
private fun replayBootstrapPackets(decoder: MediaCodecVideoDecoder) {
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
if (snapshot.isEmpty()) {
return
}
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
snapshot.forEachIndexed { index, packet ->
if (!decoder.isUsable()) {
Log.w(TAG, "replayBootstrapPackets(): decoder unusable, aborting replay at $index")
return
}
runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
}
}
}
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) {
val cached = CachedPacket(
data = packet.data.copyOf(),
ptsUs = packet.ptsUs,
isConfig = packet.isConfig,
isKeyFrame = packet.isKeyFrame,
)
synchronized(bootstrapLock) {
if (cached.isConfig) {
latestConfigPacket = cached
bootstrapPackets.clear()
bootstrapPackets.addLast(cached)
return
}
if (cached.isKeyFrame) {
bootstrapPackets.clear()
latestConfigPacket?.let { bootstrapPackets.addLast(it) }
bootstrapPackets.addLast(cached)
return
}
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
bootstrapPackets.removeFirst()
}
bootstrapPackets.addLast(cached)
}
}
private data class CachedPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CachedPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
companion object {
private const val TAG = "VideoDecoderController"
private const val MAX_BOOTSTRAP_PACKETS = 90
}
}

View File

@@ -2,8 +2,11 @@ package io.github.miuzarte.scrcpyforandroid.pages
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -59,7 +62,7 @@ fun FileManagerScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val pathStack by viewModel.pathStack.collectAsState() val breadcrumbState by viewModel.breadcrumbState.collectAsState()
val currentPath by viewModel.currentPath.collectAsState() val currentPath by viewModel.currentPath.collectAsState()
val cachedEntries by viewModel.cachedEntries.collectAsState() val cachedEntries by viewModel.cachedEntries.collectAsState()
val loading by viewModel.loading.collectAsState() val loading by viewModel.loading.collectAsState()
@@ -117,7 +120,7 @@ fun FileManagerScreen(
if (pendingTreeDownload != null) treeLauncher.launch(null) if (pendingTreeDownload != null) treeLauncher.launch(null)
} }
DisposableEffect(pathStack.size) { DisposableEffect(canNavigateUp) {
onCanNavigateUpChange(canNavigateUp) onCanNavigateUpChange(canNavigateUp)
onNavigateUpActionChange?.invoke(viewModel::navigateUp) onNavigateUpActionChange?.invoke(viewModel::navigateUp)
onDispose { onDispose {
@@ -144,7 +147,7 @@ fun FileManagerScreen(
haptic.contextClick() haptic.contextClick()
viewModel.navigateUp() viewModel.navigateUp()
}, },
enabled = pathStack.size > 1, enabled = canNavigateUp,
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -153,30 +156,21 @@ fun FileManagerScreen(
} }
}, },
bottomContent = { bottomContent = {
Text( val breadcrumbItems = remember(breadcrumbState.stack) {
text = currentPath, breadcrumbState.stack.map { path ->
modifier = Modifier BreadcrumbItem(
.fillMaxWidth() path = path,
.combinedClickable( text = path.substringAfterLast('/').ifEmpty { "/" },
onClick = {
haptic.contextClick()
pathInput = currentPath
showPathDialog = true
},
onLongClick = {
// 自带 haptic
pathInput = currentPath
showPathDialog = true
},
) )
.padding( }
start = UiSpacing.PageHorizontal, }
end = UiSpacing.PageHorizontal, BreadcrumbBar(
bottom = UiSpacing.Medium, items = breadcrumbItems,
), onItemClick = { index ->
softWrap = false, haptic.contextClick()
overflow = TextOverflow.Ellipsis, viewModel.jumpToPath(breadcrumbState.stack[index])
color = colorScheme.onSurfaceVariantSummary, },
highlightIndex = breadcrumbState.highlightIndex,
) )
}, },
actions = { actions = {
@@ -241,6 +235,13 @@ fun FileManagerScreen(
OverlayIconDropdownMenu( OverlayIconDropdownMenu(
entry = DropdownEntry( entry = DropdownEntry(
items = listOf( items = listOf(
DropdownItem(
text = stringResource(R.string.fm_goto_path),
onClick = {
pathInput = currentPath
showPathDialog = true
},
),
DropdownItem( DropdownItem(
text = stringResource(R.string.fm_menu_create_folder), text = stringResource(R.string.fm_menu_create_folder),
onClick = { onClick = {
@@ -708,3 +709,43 @@ private fun buildDetailsText(
} }
return details.toString() return details.toString()
} }
private data class BreadcrumbItem(
val path: String,
val text: String,
)
@Composable
private fun BreadcrumbBar(
items: List<BreadcrumbItem>,
onItemClick: (Int) -> Unit,
highlightIndex: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = UiSpacing.Medium),
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, item ->
if (index > 0) {
Text(
text = " / ",
color = colorScheme.onSurface.copy(alpha = 0.5f),
fontSize = 12.sp,
)
}
val isHighlighted = index == highlightIndex
Text(
text = item.text,
color = if (isHighlighted) colorScheme.primary else colorScheme.onSurface,
fontSize = 12.sp,
modifier = Modifier
.clickable(onClick = { onItemClick(index) })
.padding(vertical = 4.dp),
)
}
}
}

View File

@@ -36,17 +36,60 @@ internal class FileManagerViewModel: ViewModel() {
) )
private val asBundle = asBundleSync.value private val asBundle = asBundleSync.value
private val _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH)) private val _currentPath = MutableStateFlow(INITIAL_REMOTE_PATH)
val pathStack: StateFlow<List<String>> = _pathStack.asStateFlow() val currentPath: StateFlow<String> = _currentPath.asStateFlow()
val currentPath: StateFlow<String> = _pathStack
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH } private val pathHistory = ArrayDeque<String>().apply { addLast(INITIAL_REMOTE_PATH) }
private val historyCapacity = 20
private fun addToHistory(path: String) {
pathHistory.remove(path)
pathHistory.addLast(path)
while (pathHistory.size > historyCapacity) pathHistory.removeFirst()
}
private fun trimHistory(path: String) {
pathHistory.retainAll { it == path || it.startsWith("$path/") || path.startsWith("$it/") }
}
private fun longestHistoricalPrefix(current: String): String {
val prefixMatch: (String) -> Boolean =
if (current == ROOT_REMOTE_PATH) {
{ true }
} else {
{ hist -> hist == current || hist.startsWith("$current/") }
}
var best = current
var bestLen = current.length
pathHistory.forEach { hist ->
if (hist.length > bestLen && prefixMatch(hist)) {
best = hist
bestLen = hist.length
}
}
return best
}
internal data class BreadcrumbState(
val stack: List<String>,
val highlightIndex: Int,
)
val breadcrumbState: StateFlow<BreadcrumbState> = _currentPath
.map { current ->
val longest = longestHistoricalPrefix(current)
val stack = buildPathStack(longest)
val highlight = buildPathStack(current).lastIndex
BreadcrumbState(stack = stack, highlightIndex = highlight)
}
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
INITIAL_REMOTE_PATH, BreadcrumbState(buildPathStack(INITIAL_REMOTE_PATH), buildPathStack(INITIAL_REMOTE_PATH).lastIndex),
) )
val canNavigateUp: StateFlow<Boolean> = _pathStack
.map { it.size > 1 } val canNavigateUp: StateFlow<Boolean> = _currentPath
.map { it != ROOT_REMOTE_PATH }
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
@@ -145,21 +188,31 @@ internal class FileManagerViewModel: ViewModel() {
} }
fun navigateUp(): Boolean { fun navigateUp(): Boolean {
val stack = _pathStack.value val current = _currentPath.value
if (stack.size <= 1) return false if (current == ROOT_REMOTE_PATH) return false
_pathStack.update { it.dropLast(1) } val parent = current.substringBeforeLast('/', ROOT_REMOTE_PATH)
_currentPath.value = parent
trimHistory(parent)
return true return true
} }
fun jumpToPath(rawPath: String) { fun jumpToPath(rawPath: String) {
val normalized = normalizePath(rawPath) val normalized = normalizePath(rawPath)
_pathStack.value = buildPathStack(normalized) _currentPath.value = normalized
addToHistory(normalized)
trimHistory(normalized)
}
private fun pushPath(path: String) {
_currentPath.value = path
addToHistory(path)
trimHistory(path)
} }
fun openEntry(entry: RemoteFileEntry) { fun openEntry(entry: RemoteFileEntry) {
when { when {
entry.isDirectory -> { entry.isDirectory -> {
_pathStack.update { it + normalizePath(entry.fullPath) } pushPath(normalizePath(entry.fullPath))
} }
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {

View File

@@ -1779,6 +1779,16 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.scrcpyopt_ignore_video_encoder_constraints),
summary = "--ignore-video-encoder-constraints",
checked = soBundle.ignoreVideoEncoderConstraints,
onCheckedChange = {
soBundle = soBundle.copy(
ignoreVideoEncoderConstraints = it,
)
},
)
} }
} }

View File

@@ -467,6 +467,16 @@ fun SettingsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.pref_title_downsize_on_decode_error),
summary = stringResource(R.string.pref_summary_downsize_on_decode_error),
checked = asBundle.downsizeOnDecodeError,
onCheckedChange = {
asBundle = asBundle.copy(
downsizeOnDecodeError = it,
)
},
)
SwitchPreference( SwitchPreference(
title = stringResource(R.string.pref_title_debug_info), title = stringResource(R.string.pref_title_debug_info),
summary = stringResource(R.string.pref_summary_debug_info), summary = stringResource(R.string.pref_summary_debug_info),

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -52,10 +53,10 @@ import kotlin.math.max
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
private const val LOG_TAG = "TerminalScreen" private const val LOG_TAG = "TerminalScreen"
private fun terminalFontFile(context: android.content.Context) = private fun terminalFontFile(context: Context) =
File(context.filesDir, "terminal/font.ttf") File(context.filesDir, "terminal/font.ttf")
private fun loadTerminalTypeface(context: android.content.Context): Typeface? { private fun loadTerminalTypeface(context: Context): Typeface? {
val file = terminalFontFile(context) val file = terminalFontFile(context)
if (!file.exists()) return null if (!file.exists()) return null
return runCatching { Typeface.createFromFile(file) }.getOrNull() return runCatching { Typeface.createFromFile(file) }.getOrNull()

View File

@@ -212,6 +212,12 @@ data class ClientOptions(
// --flex-display // --flex-display
var flexDisplay: Boolean = false, // to server var flexDisplay: Boolean = false, // to server
// --ignore-video-encoder-constraints
var ignoreVideoEncoderConstraints: Boolean = false, // to server
// --no-terminal-title
// var updateTerminalTitle: Boolean = true,
) { ) {
enum class KeyInjectMode(val string: String) { enum class KeyInjectMode(val string: String) {
MIXED("mixed"), MIXED("mixed"),
@@ -633,6 +639,7 @@ data class ClientOptions(
vdSystemDecorations = vdSystemDecorations, vdSystemDecorations = vdSystemDecorations,
keepActive = keepActive, keepActive = keepActive,
flexDisplay = flexDisplay, flexDisplay = flexDisplay,
ignoreVideoEncoderConstraints = ignoreVideoEncoderConstraints,
list = list, list = list,
) )
} }

View File

@@ -116,9 +116,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-v4.0" const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.1"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0" const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.1"
const val DEFAULT_SERVER_VERSION = "4.0" const val DEFAULT_SERVER_VERSION = "4.1"
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
@@ -202,7 +202,7 @@ class Scrcpy(
// 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, this@Scrcpy) NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy, options)
} }
// Setup audio player // Setup audio player
@@ -389,6 +389,10 @@ class Scrcpy(
session.setClipboard(text, paste) session.setClipboard(text, paste)
} }
suspend fun scanFile(path: String) = withContext(Dispatchers.IO) {
session.scanFile(path)
}
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -1317,6 +1321,10 @@ class Scrcpy(
withControlWriter("setClipboard") { setClipboard(text, paste) } withControlWriter("setClipboard") { setClipboard(text, paste) }
} }
suspend fun scanFile(path: String) = mutex.withLock {
withControlWriter("scanFile") { scanFile(path) }
}
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -1775,6 +1783,16 @@ class Scrcpy(
output.flush() output.flush()
} }
@Synchronized
fun scanFile(path: String) {
val bytes = path.toByteArray(Charsets.UTF_8)
require(bytes.size <= 256) { "scan file path is too long (max 256 bytes)" }
output.writeByte(TYPE_SCAN_FILE)
output.writeInt(bytes.size)
output.write(bytes)
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)
@@ -1836,6 +1854,7 @@ class Scrcpy(
private const val TYPE_CAMERA_ZOOM_IN = 19 private const val TYPE_CAMERA_ZOOM_IN = 19
private const val TYPE_CAMERA_ZOOM_OUT = 20 private const val TYPE_CAMERA_ZOOM_OUT = 20
private const val TYPE_RESIZE_DISPLAY = 21 private const val TYPE_RESIZE_DISPLAY = 21
private const val TYPE_SCAN_FILE = 22
private const val SEQUENCE_INVALID = 0L private const val SEQUENCE_INVALID = 0L

View File

@@ -86,6 +86,7 @@ data class ServerParams(
val vdSystemDecorations: Boolean, val vdSystemDecorations: Boolean,
val keepActive: Boolean, val keepActive: Boolean,
val flexDisplay: Boolean, val flexDisplay: Boolean,
val ignoreVideoEncoderConstraints: Boolean,
val list: ListOptions, val list: ListOptions,
) { ) {
@@ -268,6 +269,9 @@ data class ServerParams(
if (flexDisplay) { if (flexDisplay) {
cmd.add("flex_display=true") cmd.add("flex_display=true")
} }
if (ignoreVideoEncoderConstraints) {
cmd.add("ignore_video_encoder_constraints=true")
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) { if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
cmd.add("display_ime_policy=${displayImePolicy.string}") cmd.add("display_ime_policy=${displayImePolicy.string}")
} }

View File

@@ -82,18 +82,21 @@ class Shared {
enum class Codec( enum class Codec(
val string: String, val string: String,
val displayName: String, val displayName: String,
val mime: String,
val type: Type, val type: Type,
val id: Int, val id: Int,
val isLossless: Boolean, val isLossless: Boolean,
) { ) {
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing H264("h264", "H.264", "video/avc", Type.VIDEO, 0x68323634, false), // default, ignore when passing
H265("h265", "H.265", Type.VIDEO, 0x68323635, false), H265("h265", "H.265", "video/hevc", Type.VIDEO, 0x68323635, false),
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false), AV1("av1", "AV1", "video/av01", Type.VIDEO, 0x00617631, false),
VP8("vp8", "VP8", "video/x-vnd.on2.vp8", Type.VIDEO, 0x00767038, false),
VP9("vp9", "VP9", "video/x-vnd.on2.vp9", Type.VIDEO, 0x00767039, false),
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing OPUS("opus", "Opus", "audio/opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false), AAC("aac", "AAC", "audio/mp4a-latm", Type.AUDIO, 0x00616163, false),
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true), FLAC("flac", "FLAC", "audio/flac", Type.AUDIO, 0x666c6163, true),
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw RAW("raw", "RAW", "audio/raw", Type.AUDIO, 0x00726177, true); // wav raw
enum class Type { VIDEO, AUDIO } enum class Type { VIDEO, AUDIO }

View File

@@ -127,6 +127,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
booleanPreferencesKey("low_latency"), booleanPreferencesKey("low_latency"),
false, false,
) )
val DOWNSIZE_ON_DECODE_ERROR = Pair(
booleanPreferencesKey("downsize_on_decode_error"),
true,
)
val FULLSCREEN_DEBUG_INFO = Pair( val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"), booleanPreferencesKey("fullscreen_debug_info"),
false, false,
@@ -318,6 +322,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
val lowLatency: Boolean, val lowLatency: Boolean,
val downsizeOnDecodeError: Boolean,
val fullscreenDebugInfo: Boolean, val fullscreenDebugInfo: Boolean,
val hideSimpleConfigItems: Boolean, val hideSimpleConfigItems: Boolean,
val previewCardOnTop: Boolean, val previewCardOnTop: Boolean,
@@ -383,6 +388,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
bundleField(LOW_LATENCY) { it.lowLatency }, bundleField(LOW_LATENCY) { it.lowLatency },
bundleField(DOWNSIZE_ON_DECODE_ERROR) { it.downsizeOnDecodeError },
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo }, bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems }, bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop }, bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
@@ -449,6 +455,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
lowLatency = preferences.read(LOW_LATENCY), lowLatency = preferences.read(LOW_LATENCY),
downsizeOnDecodeError = preferences.read(DOWNSIZE_ON_DECODE_ERROR),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS), hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP), previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),

View File

@@ -283,6 +283,10 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("flex_display"), booleanPreferencesKey("flex_display"),
false, false,
) )
val IGNORE_VIDEO_ENCODER_CONSTRAINTS = Pair(
booleanPreferencesKey("ignore_video_encoder_constraints"),
false,
)
fun defaultBundle() = Bundle( fun defaultBundle() = Bundle(
crop = CROP.defaultValue, crop = CROP.defaultValue,
@@ -351,6 +355,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = CAMERA_TORCH.defaultValue, cameraTorch = CAMERA_TORCH.defaultValue,
keepActive = KEEP_ACTIVE.defaultValue, keepActive = KEEP_ACTIVE.defaultValue,
flexDisplay = FLEX_DISPLAY.defaultValue, flexDisplay = FLEX_DISPLAY.defaultValue,
ignoreVideoEncoderConstraints = IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
) )
} }
@@ -422,6 +427,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
val cameraTorch: Boolean, val cameraTorch: Boolean,
val keepActive: Boolean, val keepActive: Boolean,
val flexDisplay: Boolean, val flexDisplay: Boolean,
val ignoreVideoEncoderConstraints: Boolean,
): Parcelable { ): Parcelable {
} }
@@ -491,6 +497,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
bundleField(CAMERA_TORCH) { it.cameraTorch }, bundleField(CAMERA_TORCH) { it.cameraTorch },
bundleField(KEEP_ACTIVE) { it.keepActive }, bundleField(KEEP_ACTIVE) { it.keepActive },
bundleField(FLEX_DISPLAY) { it.flexDisplay }, bundleField(FLEX_DISPLAY) { it.flexDisplay },
bundleField(IGNORE_VIDEO_ENCODER_CONSTRAINTS) { it.ignoreVideoEncoderConstraints },
) )
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences) val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -562,6 +569,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = preferences.read(CAMERA_TORCH), cameraTorch = preferences.read(CAMERA_TORCH),
keepActive = preferences.read(KEEP_ACTIVE), keepActive = preferences.read(KEEP_ACTIVE),
flexDisplay = preferences.read(FLEX_DISPLAY), flexDisplay = preferences.read(FLEX_DISPLAY),
ignoreVideoEncoderConstraints = preferences.read(IGNORE_VIDEO_ENCODER_CONSTRAINTS),
) )
suspend fun loadBundle() = loadBundle(::bundleFromPreferences) suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
@@ -645,6 +653,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = bundle.cameraTorch, cameraTorch = bundle.cameraTorch,
keepActive = bundle.keepActive, keepActive = bundle.keepActive,
flexDisplay = bundle.flexDisplay, flexDisplay = bundle.flexDisplay,
ignoreVideoEncoderConstraints = bundle.ignoreVideoEncoderConstraints,
) )
} }
@@ -716,6 +725,7 @@ internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
.put("cameraTorch", bundle.cameraTorch) .put("cameraTorch", bundle.cameraTorch)
.put("keepActive", bundle.keepActive) .put("keepActive", bundle.keepActive)
.put("flexDisplay", bundle.flexDisplay) .put("flexDisplay", bundle.flexDisplay)
.put("ignoreVideoEncoderConstraints", bundle.ignoreVideoEncoderConstraints)
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle { internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
val json = bundleJson ?: return ScrcpyOptions.defaultBundle() val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
@@ -984,6 +994,10 @@ internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle
"flexDisplay", "flexDisplay",
ScrcpyOptions.FLEX_DISPLAY.defaultValue, ScrcpyOptions.FLEX_DISPLAY.defaultValue,
), ),
ignoreVideoEncoderConstraints = json.optBooleanOrDefault(
"ignoreVideoEncoderConstraints",
ScrcpyOptions.IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
),
) )
} }

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +34,7 @@ class ScrcpyProfiles(context: Context): Settings(context, "ScrcpyProfiles") {
val state: StateFlow<State> = createBundleState(::stateFromPreferences) val state: StateFlow<State> = createBundleState(::stateFromPreferences)
private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State { private fun stateFromPreferences(preferences: Preferences): State {
val raw = preferences.read(PROFILES_JSON) val raw = preferences.read(PROFILES_JSON)
return normalizeState( return normalizeState(
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()), runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()),

View File

@@ -63,14 +63,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.ui.confirm import io.github.miuzarte.scrcpyforandroid.ui.confirm
import io.github.miuzarte.scrcpyforandroid.ui.contextClick import io.github.miuzarte.scrcpyforandroid.ui.contextClick
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
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 kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.* import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.AddCircle import top.yukonga.miuix.kmp.icon.extended.AddCircle

View File

@@ -168,6 +168,8 @@
<string name="section_screen_mirroring">投屏</string> <string name="section_screen_mirroring">投屏</string>
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</string> <string name="pref_title_low_latency_audio">低延迟音频(实验性)</string>
<string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string> <string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string>
<string name="pref_title_downsize_on_decode_error">解码出错时自动降级</string>
<string name="pref_summary_downsize_on_decode_error">当本机解码器不支持当前视频分辨率时,自动以较低的 max_size 重启会话,关闭后仅提示错误</string>
<string name="pref_title_debug_info">启用调试信息</string> <string name="pref_title_debug_info">启用调试信息</string>
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string> <string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string> <string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
@@ -534,6 +536,7 @@
<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_flex_display">自动调整尺寸以匹配界面</string>
<string name="scrcpyopt_ignore_video_encoder_constraints">忽略视频编码器约束</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>
@@ -574,6 +577,9 @@
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string> <string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string> <string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
<string name="vm_session_disconnected">Scrcpy 连接已断开</string> <string name="vm_session_disconnected">Scrcpy 连接已断开</string>
<string name="vm_decoder_unsupported_size">设备解码器不支持 %1$dx%2$d正在降级为 max_size=%3$d 并重启</string>
<string name="vm_decoder_init_failed">解码器初始化失败:%1$s</string>
<string name="vm_decoder_error_restarting">解码器错误,正在重启会话</string>
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string> <string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string> <string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string> <string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -169,6 +169,8 @@
<string name="section_screen_mirroring">Screen mirroring</string> <string name="section_screen_mirroring">Screen mirroring</string>
<string name="pref_title_low_latency_audio">Low latency audio (experimental)</string> <string name="pref_title_low_latency_audio">Low latency audio (experimental)</string>
<string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string> <string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string>
<string name="pref_title_downsize_on_decode_error">Downscale on decoder error</string>
<string name="pref_summary_downsize_on_decode_error">When the local decoder cannot handle the video resolution, automatically restart the session with a lower max_size. Disable to show an error instead</string>
<string name="pref_title_debug_info">Enable debug info</string> <string name="pref_title_debug_info">Enable debug info</string>
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string> <string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string> <string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
@@ -372,14 +374,14 @@
<string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string> <string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string>
<string name="password_agree">Agree</string> <string name="password_agree">Agree</string>
<string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string> <string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string>
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned.</string> <string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_continue_disable">Continue to disable</string> <string name="password_continue_disable">Continue to disable</string>
<string name="password_delete_confirm">Delete password</string> <string name="password_delete_confirm">Delete password</string>
<string name="password_delete_msg">Will delete %1$s</string> <string name="password_delete_msg">Will delete %1$s</string>
<string name="password_default_name">Password %1$d</string> <string name="password_default_name">Password %1$d</string>
<string name="password_cannot_be_empty">Password cannot be empty</string> <string name="password_cannot_be_empty">Password cannot be empty</string>
<string name="password_require_auth">Require authentication when filling passwords</string> <string name="password_require_auth">Require authentication when filling passwords</string>
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned.</string> <string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_no_auth_capability">Current device has no authentication capability</string> <string name="password_no_auth_capability">Current device has no authentication capability</string>
<string name="password_or_menu_hint">Or in the top-right menu</string> <string name="password_or_menu_hint">Or in the top-right menu</string>
<string name="password_status_invalidated">Invalidated</string> <string name="password_status_invalidated">Invalidated</string>
@@ -388,7 +390,7 @@
<string name="password_status_burned">Authenticated when created (burned)</string> <string name="password_status_burned">Authenticated when created (burned)</string>
<string name="password_rename">Rename password</string> <string name="password_rename">Rename password</string>
<string name="password_lockscreen_label">Lockscreen password</string> <string name="password_lockscreen_label">Lockscreen password</string>
<string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully.</string> <string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully</string>
<!-- File Manager --> <!-- File Manager -->
<string name="fm_cd_parent">Parent directory</string> <string name="fm_cd_parent">Parent directory</string>
@@ -526,15 +528,16 @@
<string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string> <string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string>
<string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string> <string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string>
<string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string> <string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string>
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior.</string> <string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior</string>
<string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string> <string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string>
<string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string> <string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string>
<string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string> <string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string>
<string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string> <string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string>
<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_flex_display">Automatically resize to match the screen</string>
<string name="scrcpyopt_ignore_video_encoder_constraints">Ignore video encoder constraints</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>
@@ -575,6 +578,9 @@
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string> <string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string> <string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
<string name="vm_session_disconnected">Scrcpy session disconnected</string> <string name="vm_session_disconnected">Scrcpy session disconnected</string>
<string name="vm_decoder_unsupported_size">Device decoder does not support %1$dx%2$d, downgrading to max_size=%3$d and restarting</string>
<string name="vm_decoder_init_failed">Decoder initialization failed: %1$s</string>
<string name="vm_decoder_error_restarting">Decoder error, restarting session</string>
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string> <string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string> <string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string> <string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -10,7 +10,7 @@
<item name="colorSecondaryVariant">@color/teal_700</item> <item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item> <item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. --> <!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item> <item name="android:statusBarColor">@android:color/transparent</item>
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
</style> </style>
</resources> </resources>

View File

@@ -15,7 +15,7 @@ androidxNavigation3 = "1.1.4"
junit = "4.13.2" junit = "4.13.2"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
miuix = "0.9.0" miuix = "0.9.3"
backdrop = "1.0.6" backdrop = "1.0.6"
material = "1.14.0" material = "1.14.0"
reorderable = "3.1.0" reorderable = "3.1.0"

View File

@@ -7,5 +7,15 @@
"git-submodules": { "git-submodules": {
"enabled": false "enabled": false
}, },
"schedule": ["before 6am on Monday"] "schedule": [
"before 6am on Monday"
],
"packageRules": [
{
"matchPackageNames": [
"miuix"
],
"enabled": false
}
]
} }