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)
app/src/main/assets/bin/scrcpy-server-v*
docs/

View File

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

View File

@@ -59,8 +59,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 37
versionCode = 33
versionName = "0.4.3_pre1"
versionCode = 36
versionName = "0.5.0"
externalNativeBuild {
cmake {
@@ -168,9 +168,9 @@ dependencies {
}
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0"
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0"
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a"
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.1"
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-server-v4.1"
val scrcpyServerSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
val downloadScrcpyServer by tasks.registering {
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
if (needsDownload) {
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...")
logger.lifecycle("Downloading scrcpy-server-v4.1 from GitHub releases...")
try {
URI(url).toURL().openStream().use { input ->
file.outputStream().use { output ->
@@ -212,7 +212,7 @@ val downloadScrcpyServer by tasks.registering {
}
} catch (e: Exception) {
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" +
" You may download it manually and place it at: ${file.absolutePath}\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)
require(actualSha == expectedSha) {
"SHA256 mismatch for scrcpy-server-v4.0!\n" +
"SHA256 mismatch for scrcpy-server-v4.1!\n" +
" Expected: $expectedSha\n" +
" Got: $actualSha\n" +
" 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 {
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
import android.os.Handler
import android.os.Looper
import android.util.Log
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.VideoDecoderController
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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:
* - Surface/Decoder management for video rendering
* - Video size and FPS monitoring
* Acts as a thin front-end over [VideoDecoderController] (decoder lifecycle, bootstrap
* cache, error detection) and [PersistentVideoRenderer] (EGL / surface management).
*
* 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 {
private val sessionLifecycleMutex = Mutex()
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val renderer = PersistentVideoRenderer()
private val controller = VideoDecoderController(renderer)
@Volatile
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
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)
@Volatile
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() {
sessionLifecycleMutex.withLock {
releaseAllDecoders()
controller.releaseAll()
renderer.release()
}
}
@@ -71,30 +81,20 @@ object NativeCoreFacade {
return
}
val newId = System.identityHashCode(surface)
if (activeSurfaceId == newId && decoder != null) {
if (activeSurfaceId == newId && controller.isDecoderUsable()) {
return
}
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
activeSurfaceId = newId
renderer.attachDisplaySurface(surface)
val session = currentSessionInfo ?: return
val currentDecoder = decoder
if (currentDecoder != null) {
controller.attachDisplaySurface(surface)
val session = controller.getCurrentSessionInfo() ?: return
if (controller.isDecoderUsable()) {
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
if (switched) {
if (controller.trySwitchDecoderSurface()) {
return
}
}
if (session.width <= 0 || session.height <= 0) {
Log.i(
TAG,
"attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})",
)
return
}
createOrReplaceDecoder(session)
controller.ensureDecoder(session)
}
}
@@ -123,12 +123,7 @@ object NativeCoreFacade {
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
)
activeSurfaceId = null
renderer.detachDisplaySurface(surface, releaseSurface = false)
if (releaseDecoder) {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release()
decoder = null
}
controller.detachDisplaySurface(surface, releaseDecoder)
}
}
@@ -140,10 +135,10 @@ object NativeCoreFacade {
) {
sessionLifecycleMutex.withLock {
recordingSurfaceAttached = true
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
val session = currentSessionInfo
if (session != null && decoder == null) {
createOrReplaceDecoder(session)
controller.attachRecordSurface(surface, width, height, onFrameRendered)
val session = controller.getCurrentSessionInfo()
if (session != null && !controller.isDecoderUsable()) {
controller.ensureDecoder(session)
}
}
}
@@ -154,94 +149,78 @@ object NativeCoreFacade {
) {
sessionLifecycleMutex.withLock {
recordingSurfaceAttached = false
renderer.detachRecordSurface(surface, releaseSurface)
controller.detachRecordSurface(surface, releaseSurface)
if (activeSurfaceId == null) {
decoder?.release()
decoder = null
controller.releaseAll()
}
}
}
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)
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = controller.addVideoSizeListener(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = controller.removeVideoSizeListener(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = controller.addVideoFpsListener(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = controller.removeVideoFpsListener(listener)
/**
* Called by Scrcpy.kt when a session starts.
* 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(
session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session,
scrcpy: Scrcpy,
options: ClientOptions,
) = sessionLifecycleMutex.withLock {
scrcpyRef = scrcpy
currentSessionInfo = session
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
cachedClientOptions = options
isRestarting = false
controller.releaseAll()
controller.resetBootstrap()
if (activeSurfaceId != null || recordingSurfaceAttached) {
// v4.0: width/height come from first video session packet, not from initial metadata
if (session.width > 0 && session.height > 0) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session)
controller.ensureDecoder(session)
} else {
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
}
}
packetCount = 0
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}",
)
}
val dec = decoder ?: return@attachVideoConsumer
runCatching {
dec.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig,
)
}
cacheAndFeed(packet)
}
}
/**
* 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.
*
* 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) {
lifecycleScope.launch {
sessionLifecycleMutex.withLock {
if (isRestarting) return@withLock
val scrcpy = scrcpyRef ?: return@withLock
val info = scrcpy.currentSessionState.value ?: return@withLock
if (info.width <= 0 || info.height <= 0) return@withLock
if (decoder == null) {
// Initial creation (v4.0: deferred until first session packet)
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}")
currentSessionInfo = info
createOrReplaceDecoder(info)
} else if (currentSessionInfo != null &&
(info.width != currentSessionInfo!!.width || info.height != currentSessionInfo!!.height)
) {
// Flex display: rebuild decoder on size change
Log.i(
val mime = info.codec?.mime ?: "video/avc"
if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
Log.w(
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
createOrReplaceDecoder(info)
handleUnsupportedSize(info.width, info.height, mime)
return@withLock
}
controller.rebuildDecoderForSize(info)
}
}
}
@@ -251,150 +230,98 @@ object NativeCoreFacade {
* Cleans up decoders and resets state.
*/
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
controller.releaseAll()
scrcpyRef = null
currentSessionInfo = null
recordingSurfaceAttached = false
}
private const val TAG = "NativeCoreFacade"
private const val MAX_BOOTSTRAP_PACKETS = 90
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
private fun cacheAndFeed(packet: Scrcpy.Session.VideoPacket) {
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} usable=${controller.isDecoderUsable()}",
)
}
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
val usable = controller.feed(packet)
if (!usable && !isRestarting) {
Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
handleDecoderError()
}
}
/**
* 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].
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
* registered listeners on the main thread.
* - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup.
* When "downsize on decode error" is enabled (default), restarts the session with a
* lower `max_size` derived from [DecoderCapabilities.maxSupportedSize]. Otherwise,
* shows an error snackbar and leaves the decoder uncreated (black screen, no crash).
*/
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
val surface = renderer.getDecoderSurface()
decoder?.release()
decoder = null
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()) {
private suspend fun handleUnsupportedSize(width: Int, height: Int, mime: String) {
val downsizeEnabled = Storage.appSettings.bundleState.value.downsizeOnDecodeError
if (!downsizeEnabled) {
AppRuntime.snackbar(R.string.vm_decoder_init_failed, "${width}x${height}")
return
}
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
snapshot.forEach { packet ->
runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
val maxSize = DecoderCapabilities.maxSupportedSize(mime)
Log.i(TAG, "handleUnsupportedSize(): downgrading to max_size=$maxSize")
AppRuntime.snackbar(R.string.vm_decoder_unsupported_size, width, height, maxSize)
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(
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
}
/**
* Restart the scrcpy session with a modified `max_size`.
*
* Persists the new max_size to [Storage.scrcpyOptions] so the user sees the change
* in the UI, then stops and restarts the session.
*/
private suspend fun downgradeAndRestart(maxSize: Int) {
if (isRestarting) return
isRestarting = true
val options = cachedClientOptions ?: return
val newOptions = options.copy(maxSize = maxSize.toUShort()).fix().validate()
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)
// Persist the new max_size so the UI reflects the downgrade.
runCatching {
val soBundle = Storage.scrcpyOptions.bundleState.value
Storage.scrcpyOptions.saveBundle(soBundle.copy(maxSize = maxSize))
}
restartSessionWith(newOptions)
}
private fun releaseAllDecoders() {
runCatching { decoder?.release() }
decoder = null
/**
* Stop the current session and start a new one with [options].
*
* 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
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 var isPipActionReceiverRegistered = false

View File

@@ -140,6 +140,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
val p = port ?: old.port
listOf("$host:$p")
}
updateById -> old.addresses
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 {
val kf = KeyFactory.getInstance("RSA")
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
return if (key is RSAPrivateCrtKey) key
else kf.generatePrivate(parsePkcs8PrivateKey(der))
return key as? RSAPrivateCrtKey ?: kf.generatePrivate(parsePkcs8PrivateKey(der))
}
private fun parsePublicKey(content: String): PublicKey {
@@ -366,7 +365,25 @@ internal object DirectAdbTransport {
private data class PemBlock(
val label: String,
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? {
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
@@ -661,9 +678,13 @@ internal class DirectAdbConnection(
fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement()
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
val stream = AdbSocketStream(localId, { command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data)
}, flowControlWindow)
val stream = AdbSocketStream(
localId,
{ command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data)
},
flowControlWindow,
)
streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try {
@@ -979,8 +1000,9 @@ class AdbSocketStream(
private val queue = LinkedBlockingQueue<Any>()
// need notifyAll() / wait()
private val writeLock = java.lang.Object()
@Volatile private var inflightWrites = 0
private val writeLock = Object()
@Volatile
private var inflightWrites = 0
private object EndOfStreamMarker

View File

@@ -6,19 +6,21 @@ import android.util.Log
import android.view.Surface
/**
* AnnexBDecoder
* MediaCodecVideoDecoder
*
* 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
* 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:
* - Public methods are synchronized to allow calls from multiple threads
* (packet producer vs. teardown). Internally, MediaCodec callbacks and
* buffer queues are used on the calling thread.
*/
class AnnexBDecoder(
class MediaCodecVideoDecoder(
width: Int,
height: Int,
outputSurface: Surface,
@@ -41,9 +43,23 @@ class AnnexBDecoder(
@Volatile
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 {
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)
if (sps != null) {
@@ -52,8 +68,14 @@ class AnnexBDecoder(
if (pps != null) {
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
}
codec.configure(format, outputSurface, null, 0)
codec.start()
try {
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
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
if (released) {
if (released || codecError != null) {
return
}
runCatching {
@@ -107,9 +129,13 @@ class AnnexBDecoder(
}
drainOutput()
}.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,
"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,
)
}
@@ -145,6 +171,12 @@ class AnnexBDecoder(
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() {
while (true) {
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
@@ -202,7 +234,7 @@ class AnnexBDecoder(
}
companion object {
private const val TAG = "AnnexBDecoder"
private const val TAG = "MediaCodecVideoDecoder"
private const val MIME_AVC = "video/avc"
private const val INPUT_TIMEOUT_US = 10_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) {
ensureInitialized()
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.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -59,7 +62,7 @@ fun FileManagerScreen(
val listState = rememberLazyListState()
val layoutDirection = LocalLayoutDirection.current
val pathStack by viewModel.pathStack.collectAsState()
val breadcrumbState by viewModel.breadcrumbState.collectAsState()
val currentPath by viewModel.currentPath.collectAsState()
val cachedEntries by viewModel.cachedEntries.collectAsState()
val loading by viewModel.loading.collectAsState()
@@ -117,7 +120,7 @@ fun FileManagerScreen(
if (pendingTreeDownload != null) treeLauncher.launch(null)
}
DisposableEffect(pathStack.size) {
DisposableEffect(canNavigateUp) {
onCanNavigateUpChange(canNavigateUp)
onNavigateUpActionChange?.invoke(viewModel::navigateUp)
onDispose {
@@ -144,7 +147,7 @@ fun FileManagerScreen(
haptic.contextClick()
viewModel.navigateUp()
},
enabled = pathStack.size > 1,
enabled = canNavigateUp,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -153,30 +156,21 @@ fun FileManagerScreen(
}
},
bottomContent = {
Text(
text = currentPath,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
haptic.contextClick()
pathInput = currentPath
showPathDialog = true
},
onLongClick = {
// 自带 haptic
pathInput = currentPath
showPathDialog = true
},
val breadcrumbItems = remember(breadcrumbState.stack) {
breadcrumbState.stack.map { path ->
BreadcrumbItem(
path = path,
text = path.substringAfterLast('/').ifEmpty { "/" },
)
.padding(
start = UiSpacing.PageHorizontal,
end = UiSpacing.PageHorizontal,
bottom = UiSpacing.Medium,
),
softWrap = false,
overflow = TextOverflow.Ellipsis,
color = colorScheme.onSurfaceVariantSummary,
}
}
BreadcrumbBar(
items = breadcrumbItems,
onItemClick = { index ->
haptic.contextClick()
viewModel.jumpToPath(breadcrumbState.stack[index])
},
highlightIndex = breadcrumbState.highlightIndex,
)
},
actions = {
@@ -241,6 +235,13 @@ fun FileManagerScreen(
OverlayIconDropdownMenu(
entry = DropdownEntry(
items = listOf(
DropdownItem(
text = stringResource(R.string.fm_goto_path),
onClick = {
pathInput = currentPath
showPathDialog = true
},
),
DropdownItem(
text = stringResource(R.string.fm_menu_create_folder),
onClick = {
@@ -708,3 +709,43 @@ private fun buildDetailsText(
}
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 _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH))
val pathStack: StateFlow<List<String>> = _pathStack.asStateFlow()
val currentPath: StateFlow<String> = _pathStack
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH }
private val _currentPath = MutableStateFlow(INITIAL_REMOTE_PATH)
val currentPath: StateFlow<String> = _currentPath.asStateFlow()
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(
viewModelScope,
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(
viewModelScope,
SharingStarted.Eagerly,
@@ -145,21 +188,31 @@ internal class FileManagerViewModel: ViewModel() {
}
fun navigateUp(): Boolean {
val stack = _pathStack.value
if (stack.size <= 1) return false
_pathStack.update { it.dropLast(1) }
val current = _currentPath.value
if (current == ROOT_REMOTE_PATH) return false
val parent = current.substringBeforeLast('/', ROOT_REMOTE_PATH)
_currentPath.value = parent
trimHistory(parent)
return true
}
fun jumpToPath(rawPath: String) {
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) {
when {
entry.isDirectory -> {
_pathStack.update { it + normalizePath(entry.fullPath) }
pushPath(normalizePath(entry.fullPath))
}
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(
title = stringResource(R.string.pref_title_debug_info),
summary = stringResource(R.string.pref_summary_debug_info),

View File

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

View File

@@ -212,6 +212,12 @@ data class ClientOptions(
// --flex-display
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) {
MIXED("mixed"),
@@ -633,6 +639,7 @@ data class ClientOptions(
vdSystemDecorations = vdSystemDecorations,
keepActive = keepActive,
flexDisplay = flexDisplay,
ignoreVideoEncoderConstraints = ignoreVideoEncoderConstraints,
list = list,
)
}

View File

@@ -116,9 +116,9 @@ class Scrcpy(
companion object {
private const val TAG = "Scrcpy"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0"
const val DEFAULT_SERVER_VERSION = "4.0"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.1"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.1"
const val DEFAULT_SERVER_VERSION = "4.1"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
// Regex patterns for parsing server output
@@ -202,7 +202,7 @@ class Scrcpy(
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy)
NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy, options)
}
// Setup audio player
@@ -389,6 +389,10 @@ class Scrcpy(
session.setClipboard(text, paste)
}
suspend fun scanFile(path: String) = withContext(Dispatchers.IO) {
session.scanFile(path)
}
suspend fun injectTouch(
action: Int,
pointerId: Long,
@@ -1317,6 +1321,10 @@ class Scrcpy(
withControlWriter("setClipboard") { setClipboard(text, paste) }
}
suspend fun scanFile(path: String) = mutex.withLock {
withControlWriter("scanFile") { scanFile(path) }
}
suspend fun injectTouch(
action: Int,
pointerId: Long,
@@ -1775,6 +1783,16 @@ class Scrcpy(
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) {
output.writeInt(x)
output.writeInt(y)
@@ -1836,6 +1854,7 @@ class Scrcpy(
private const val TYPE_CAMERA_ZOOM_IN = 19
private const val TYPE_CAMERA_ZOOM_OUT = 20
private const val TYPE_RESIZE_DISPLAY = 21
private const val TYPE_SCAN_FILE = 22
private const val SEQUENCE_INVALID = 0L

View File

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

View File

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

View File

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

View File

@@ -283,6 +283,10 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("flex_display"),
false,
)
val IGNORE_VIDEO_ENCODER_CONSTRAINTS = Pair(
booleanPreferencesKey("ignore_video_encoder_constraints"),
false,
)
fun defaultBundle() = Bundle(
crop = CROP.defaultValue,
@@ -351,6 +355,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = CAMERA_TORCH.defaultValue,
keepActive = KEEP_ACTIVE.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 keepActive: Boolean,
val flexDisplay: Boolean,
val ignoreVideoEncoderConstraints: Boolean,
): Parcelable {
}
@@ -491,6 +497,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
bundleField(CAMERA_TORCH) { it.cameraTorch },
bundleField(KEEP_ACTIVE) { it.keepActive },
bundleField(FLEX_DISPLAY) { it.flexDisplay },
bundleField(IGNORE_VIDEO_ENCODER_CONSTRAINTS) { it.ignoreVideoEncoderConstraints },
)
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -562,6 +569,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = preferences.read(CAMERA_TORCH),
keepActive = preferences.read(KEEP_ACTIVE),
flexDisplay = preferences.read(FLEX_DISPLAY),
ignoreVideoEncoderConstraints = preferences.read(IGNORE_VIDEO_ENCODER_CONSTRAINTS),
)
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
@@ -645,6 +653,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = bundle.cameraTorch,
keepActive = bundle.keepActive,
flexDisplay = bundle.flexDisplay,
ignoreVideoEncoderConstraints = bundle.ignoreVideoEncoderConstraints,
)
}
@@ -716,6 +725,7 @@ internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
.put("cameraTorch", bundle.cameraTorch)
.put("keepActive", bundle.keepActive)
.put("flexDisplay", bundle.flexDisplay)
.put("ignoreVideoEncoderConstraints", bundle.ignoreVideoEncoderConstraints)
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
@@ -984,6 +994,10 @@ internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle
"flexDisplay",
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
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +34,7 @@ class ScrcpyProfiles(context: Context): Settings(context, "ScrcpyProfiles") {
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)
return normalizeState(
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.ui.confirm
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.AddCircle

View File

@@ -168,6 +168,8 @@
<string name="section_screen_mirroring">投屏</string>
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</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_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
@@ -534,6 +536,7 @@
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
<string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</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_native">本机</string>
<string name="scrcpyopt_log_level">日志等级</string>
@@ -574,6 +577,9 @@
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</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_adb_disconnected_device">ADB 已断开:%1$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="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_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_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>
@@ -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_agree">Agree</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_delete_confirm">Delete password</string>
<string name="password_delete_msg">Will delete %1$s</string>
<string name="password_default_name">Password %1$d</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_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_or_menu_hint">Or in the top-right menu</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_rename">Rename 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 -->
<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_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_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_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_clipboard_autosync">Disable automatic clipboard synchronization</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_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_ignore_video_encoder_constraints">Ignore video encoder constraints</string>
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
<string name="scrcpyopt_native">Native</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_paste_failed">Local clipboard paste failed</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_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>

View File

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

View File

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

View File

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