Compare commits
10 Commits
8c8ccd896c
...
4e07b71ee8
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e07b71ee8 | |||
|
|
d1e9b8cfdc | ||
|
|
d1e20c2a6b | ||
|
|
a2937f5fe1 | ||
|
|
b918f18e9e | ||
|
|
90a188f355 | ||
|
|
d459fae571 | ||
|
|
30d6cb5191 | ||
|
|
14c95cd734 | ||
|
|
0fa3a3d544 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,3 +47,5 @@ abandoned_assets/
|
||||
|
||||
# scrcpy-server binary (downloaded by Gradle during build)
|
||||
app/src/main/assets/bin/scrcpy-server-v*
|
||||
|
||||
docs/
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
// Persist the new max_size so the UI reflects the downgrade.
|
||||
runCatching {
|
||||
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
|
||||
}
|
||||
}
|
||||
val soBundle = Storage.scrcpyOptions.bundleState.value
|
||||
Storage.scrcpyOptions.saveBundle(soBundle.copy(maxSize = maxSize))
|
||||
}
|
||||
|
||||
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
|
||||
restartSessionWith(newOptions)
|
||||
}
|
||||
|
||||
if (cached.isKeyFrame) {
|
||||
bootstrapPackets.clear()
|
||||
latestConfigPacket?.let { bootstrapPackets.addLast(it) }
|
||||
bootstrapPackets.addLast(cached)
|
||||
return
|
||||
/**
|
||||
* 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) }
|
||||
}
|
||||
|
||||
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
|
||||
bootstrapPackets.removeFirst()
|
||||
}
|
||||
bootstrapPackets.addLast(cached)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseAllDecoders() {
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
}
|
||||
private const val TAG = "NativeCoreFacade"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ->
|
||||
val stream = AdbSocketStream(
|
||||
localId,
|
||||
{ command, arg0, arg1, data ->
|
||||
sendMsg(command, arg0, arg1, data)
|
||||
}, flowControlWindow)
|
||||
},
|
||||
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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -7,5 +7,15 @@
|
||||
"git-submodules": {
|
||||
"enabled": false
|
||||
},
|
||||
"schedule": ["before 6am on Monday"]
|
||||
"schedule": [
|
||||
"before 6am on Monday"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"miuix"
|
||||
],
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Submodule submodule/miuix updated: b728c2d9ac...fd5362ce88
Reference in New Issue
Block a user