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)
|
# scrcpy-server binary (downloaded by Gradle during build)
|
||||||
app/src/main/assets/bin/scrcpy-server-v*
|
app/src/main/assets/bin/scrcpy-server-v*
|
||||||
|
|
||||||
|
docs/
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,9 +1,23 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
- 依赖: scrcpy 升级至 v4.1
|
||||||
|
- 新增: vp8/vp9 解码
|
||||||
|
|
||||||
|
## 0.4.5
|
||||||
|
|
||||||
|
- 重构: 改了好多不知道怎么写,总之修复了一些已知问题
|
||||||
|
|
||||||
|
## 0.4.4
|
||||||
|
|
||||||
|
- 修复: 画中画初始化空指针
|
||||||
|
|
||||||
## 0.4.3
|
## 0.4.3
|
||||||
|
|
||||||
- 改进: 对控制通道增加流控选项
|
- 改进: 对控制通道增加流控选项
|
||||||
- 新增: 预发行版更新检测
|
- 新增: 预发行版更新检测
|
||||||
|
- 改进: 文件页的路径改用新实现的面包屑
|
||||||
|
|
||||||
## 0.4.2
|
## 0.4.2
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" />
|
<img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" />
|
||||||
<img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" />
|
<img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" />
|
||||||
<img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" />
|
<img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" />
|
||||||
<img src="https://github.com/user-attachments/assets/886ba6db-d6a7-4528-96fb-bb0ce4deb953" height="300" alt="Files" />
|
<img src="https://github.com/user-attachments/assets/3e4edc6c-ddfc-478b-aa7d-aa797a9f0e74" height="300" alt="Files" />
|
||||||
<img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" />
|
<img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" />
|
||||||
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" />
|
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" />
|
||||||
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" />
|
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" />
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ android {
|
|||||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 33
|
versionCode = 36
|
||||||
versionName = "0.4.3_pre1"
|
versionName = "0.5.0"
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
@@ -168,9 +168,9 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
|
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
|
||||||
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0"
|
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.1"
|
||||||
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0"
|
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-server-v4.1"
|
||||||
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a"
|
val scrcpyServerSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
|
||||||
|
|
||||||
val downloadScrcpyServer by tasks.registering {
|
val downloadScrcpyServer by tasks.registering {
|
||||||
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
|
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
|
||||||
@@ -203,7 +203,7 @@ val downloadScrcpyServer by tasks.registering {
|
|||||||
val needsDownload = !file.exists() || computeSha256(file) != expectedSha
|
val needsDownload = !file.exists() || computeSha256(file) != expectedSha
|
||||||
|
|
||||||
if (needsDownload) {
|
if (needsDownload) {
|
||||||
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...")
|
logger.lifecycle("Downloading scrcpy-server-v4.1 from GitHub releases...")
|
||||||
try {
|
try {
|
||||||
URI(url).toURL().openStream().use { input ->
|
URI(url).toURL().openStream().use { input ->
|
||||||
file.outputStream().use { output ->
|
file.outputStream().use { output ->
|
||||||
@@ -212,7 +212,7 @@ val downloadScrcpyServer by tasks.registering {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw GradleException(
|
throw GradleException(
|
||||||
"Failed to download scrcpy-server-v4.0 from GitHub releases.\n" +
|
"Failed to download scrcpy-server-v4.1 from GitHub releases.\n" +
|
||||||
" URL: $url\n" +
|
" URL: $url\n" +
|
||||||
" You may download it manually and place it at: ${file.absolutePath}\n" +
|
" You may download it manually and place it at: ${file.absolutePath}\n" +
|
||||||
" If you are behind a proxy, check your Gradle proxy settings\n" +
|
" If you are behind a proxy, check your Gradle proxy settings\n" +
|
||||||
@@ -223,14 +223,14 @@ val downloadScrcpyServer by tasks.registering {
|
|||||||
|
|
||||||
val actualSha = computeSha256(file)
|
val actualSha = computeSha256(file)
|
||||||
require(actualSha == expectedSha) {
|
require(actualSha == expectedSha) {
|
||||||
"SHA256 mismatch for scrcpy-server-v4.0!\n" +
|
"SHA256 mismatch for scrcpy-server-v4.1!\n" +
|
||||||
" Expected: $expectedSha\n" +
|
" Expected: $expectedSha\n" +
|
||||||
" Got: $actualSha\n" +
|
" Got: $actualSha\n" +
|
||||||
" Delete ${file.absolutePath} to retry download."
|
" Delete ${file.absolutePath} to retry download."
|
||||||
}
|
}
|
||||||
logger.lifecycle("scrcpy-server-v4.0 downloaded and verified.")
|
logger.lifecycle("scrcpy-server-v4.1 downloaded and verified.")
|
||||||
} else {
|
} else {
|
||||||
logger.lifecycle("scrcpy-server-v4.0 exists with correct SHA256, skip download.")
|
logger.lifecycle("scrcpy-server-v4.1 exists with correct SHA256, skip download.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,68 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
import io.github.miuzarte.scrcpyforandroid.nativecore.DecoderCapabilities
|
||||||
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.nativecore.VideoDecoderController
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.util.ArrayDeque
|
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Facade that centralizes video rendering.
|
* Facade that centralizes video rendering and decoder management.
|
||||||
*
|
*
|
||||||
* Provides helpers for:
|
* Acts as a thin front-end over [VideoDecoderController] (decoder lifecycle, bootstrap
|
||||||
* - Surface/Decoder management for video rendering
|
* cache, error detection) and [PersistentVideoRenderer] (EGL / surface management).
|
||||||
* - Video size and FPS monitoring
|
*
|
||||||
|
* The facade owns the session lifecycle mutex and coordinates surface attach/detach
|
||||||
|
* with decoder creation. It also publishes video size / FPS to listeners.
|
||||||
|
*
|
||||||
|
* Error recovery:
|
||||||
|
* - When the local decoder cannot handle the video resolution (e.g. MTK AVC caps at
|
||||||
|
* 2048 but scrcpy sends 2400), the facade can automatically restart the session with
|
||||||
|
* a lower `max_size` (controlled by the "downsize on decode error" app setting).
|
||||||
|
* - When the decoder enters an unrecoverable error state at runtime (e.g. MTK OMX
|
||||||
|
* buffer conflict after rotation), the facade restarts the session.
|
||||||
*/
|
*/
|
||||||
object NativeCoreFacade {
|
object NativeCoreFacade {
|
||||||
private val sessionLifecycleMutex = Mutex()
|
private val sessionLifecycleMutex = Mutex()
|
||||||
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private val renderer = PersistentVideoRenderer()
|
private val renderer = PersistentVideoRenderer()
|
||||||
|
private val controller = VideoDecoderController(renderer)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var activeSurfaceId: Int? = null
|
private var activeSurfaceId: Int? = null
|
||||||
private var decoder: AnnexBDecoder? = null
|
|
||||||
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
|
||||||
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
private val bootstrapLock = Any()
|
|
||||||
private val bootstrapPackets = ArrayDeque<CachedPacket>()
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var recordingSurfaceAttached = false
|
private var recordingSurfaceAttached = false
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var latestConfigPacket: CachedPacket? = null
|
|
||||||
private var packetCount: Long = 0
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
|
|
||||||
|
|
||||||
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
|
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var scrcpyRef: Scrcpy? = null
|
private var scrcpyRef: Scrcpy? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var packetCount: Long = 0
|
||||||
|
|
||||||
|
// Cached ClientOptions for session restart (downgrade / error recovery)
|
||||||
|
@Volatile
|
||||||
|
private var cachedClientOptions: ClientOptions? = null
|
||||||
|
|
||||||
|
// Guards against recursive restarts (restart triggers onScrcpySessionStopped/Started)
|
||||||
|
@Volatile
|
||||||
|
private var isRestarting = false
|
||||||
|
|
||||||
suspend fun close() {
|
suspend fun close() {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
releaseAllDecoders()
|
controller.releaseAll()
|
||||||
renderer.release()
|
renderer.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,30 +81,20 @@ object NativeCoreFacade {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val newId = System.identityHashCode(surface)
|
val newId = System.identityHashCode(surface)
|
||||||
if (activeSurfaceId == newId && decoder != null) {
|
if (activeSurfaceId == newId && controller.isDecoderUsable()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
|
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
|
||||||
activeSurfaceId = newId
|
activeSurfaceId = newId
|
||||||
renderer.attachDisplaySurface(surface)
|
controller.attachDisplaySurface(surface)
|
||||||
val session = currentSessionInfo ?: return
|
val session = controller.getCurrentSessionInfo() ?: return
|
||||||
val currentDecoder = decoder
|
if (controller.isDecoderUsable()) {
|
||||||
if (currentDecoder != null) {
|
|
||||||
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
|
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
|
||||||
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
|
if (controller.trySwitchDecoderSurface()) {
|
||||||
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
|
|
||||||
if (switched) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (session.width <= 0 || session.height <= 0) {
|
controller.ensureDecoder(session)
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createOrReplaceDecoder(session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +123,7 @@ object NativeCoreFacade {
|
|||||||
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
|
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
|
||||||
)
|
)
|
||||||
activeSurfaceId = null
|
activeSurfaceId = null
|
||||||
renderer.detachDisplaySurface(surface, releaseSurface = false)
|
controller.detachDisplaySurface(surface, releaseDecoder)
|
||||||
if (releaseDecoder) {
|
|
||||||
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
|
|
||||||
decoder?.release()
|
|
||||||
decoder = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +135,10 @@ object NativeCoreFacade {
|
|||||||
) {
|
) {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
recordingSurfaceAttached = true
|
recordingSurfaceAttached = true
|
||||||
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
|
controller.attachRecordSurface(surface, width, height, onFrameRendered)
|
||||||
val session = currentSessionInfo
|
val session = controller.getCurrentSessionInfo()
|
||||||
if (session != null && decoder == null) {
|
if (session != null && !controller.isDecoderUsable()) {
|
||||||
createOrReplaceDecoder(session)
|
controller.ensureDecoder(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,94 +149,78 @@ object NativeCoreFacade {
|
|||||||
) {
|
) {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
recordingSurfaceAttached = false
|
recordingSurfaceAttached = false
|
||||||
renderer.detachRecordSurface(surface, releaseSurface)
|
controller.detachRecordSurface(surface, releaseSurface)
|
||||||
if (activeSurfaceId == null) {
|
if (activeSurfaceId == null) {
|
||||||
decoder?.release()
|
controller.releaseAll()
|
||||||
decoder = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
|
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = controller.addVideoSizeListener(listener)
|
||||||
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener)
|
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = controller.removeVideoSizeListener(listener)
|
||||||
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener)
|
fun addVideoFpsListener(listener: (Float) -> Unit) = controller.addVideoFpsListener(listener)
|
||||||
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener)
|
fun removeVideoFpsListener(listener: (Float) -> Unit) = controller.removeVideoFpsListener(listener)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by Scrcpy.kt when a session starts.
|
* Called by Scrcpy.kt when a session starts.
|
||||||
* Sets up video decoders for registered surfaces.
|
* Sets up video decoders for registered surfaces.
|
||||||
|
*
|
||||||
|
* [options] is cached so the facade can restart the session (with a modified max_size)
|
||||||
|
* when the decoder fails.
|
||||||
*/
|
*/
|
||||||
suspend fun onScrcpySessionStarted(
|
suspend fun onScrcpySessionStarted(
|
||||||
session: Scrcpy.Session.SessionInfo,
|
session: Scrcpy.Session.SessionInfo,
|
||||||
sessionMgr: Scrcpy.Session,
|
sessionMgr: Scrcpy.Session,
|
||||||
scrcpy: Scrcpy,
|
scrcpy: Scrcpy,
|
||||||
|
options: ClientOptions,
|
||||||
) = sessionLifecycleMutex.withLock {
|
) = sessionLifecycleMutex.withLock {
|
||||||
scrcpyRef = scrcpy
|
scrcpyRef = scrcpy
|
||||||
currentSessionInfo = session
|
cachedClientOptions = options
|
||||||
releaseAllDecoders()
|
isRestarting = false
|
||||||
synchronized(bootstrapLock) {
|
controller.releaseAll()
|
||||||
bootstrapPackets.clear()
|
controller.resetBootstrap()
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
if (activeSurfaceId != null || recordingSurfaceAttached) {
|
if (activeSurfaceId != null || recordingSurfaceAttached) {
|
||||||
// v4.0: width/height come from first video session packet, not from initial metadata
|
// v4.0: width/height come from first video session packet, not from initial metadata
|
||||||
if (session.width > 0 && session.height > 0) {
|
if (session.width > 0 && session.height > 0) {
|
||||||
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
||||||
createOrReplaceDecoder(session)
|
controller.ensureDecoder(session)
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
|
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packetCount = 0
|
packetCount = 0
|
||||||
sessionMgr.attachVideoConsumer { packet ->
|
sessionMgr.attachVideoConsumer { packet ->
|
||||||
cacheBootstrapPacket(packet)
|
cacheAndFeed(packet)
|
||||||
packetCount += 1
|
|
||||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val dec = decoder ?: return@attachVideoConsumer
|
|
||||||
runCatching {
|
|
||||||
dec.feedAnnexB(
|
|
||||||
packet.data,
|
|
||||||
packet.ptsUs,
|
|
||||||
packet.isKeyFrame,
|
|
||||||
packet.isConfig,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by Scrcpy when a video session packet arrives with new dimensions.
|
* Called by Scrcpy when a video session packet arrives with new dimensions.
|
||||||
* Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
|
* Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
|
||||||
|
*
|
||||||
|
* Before creating the decoder, checks whether the local hardware decoder supports the
|
||||||
|
* target resolution. If not, triggers a downgrade restart (when enabled) or shows an
|
||||||
|
* error snackbar.
|
||||||
*/
|
*/
|
||||||
fun onVideoSizeChanged(width: Int, height: Int) {
|
fun onVideoSizeChanged(width: Int, height: Int) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
sessionLifecycleMutex.withLock {
|
sessionLifecycleMutex.withLock {
|
||||||
|
if (isRestarting) return@withLock
|
||||||
val scrcpy = scrcpyRef ?: return@withLock
|
val scrcpy = scrcpyRef ?: return@withLock
|
||||||
val info = scrcpy.currentSessionState.value ?: return@withLock
|
val info = scrcpy.currentSessionState.value ?: return@withLock
|
||||||
if (info.width <= 0 || info.height <= 0) return@withLock
|
if (info.width <= 0 || info.height <= 0) return@withLock
|
||||||
|
|
||||||
if (decoder == null) {
|
val mime = info.codec?.mime ?: "video/avc"
|
||||||
// Initial creation (v4.0: deferred until first session packet)
|
if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
|
||||||
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}")
|
Log.w(
|
||||||
currentSessionInfo = info
|
|
||||||
createOrReplaceDecoder(info)
|
|
||||||
} else if (currentSessionInfo != null &&
|
|
||||||
(info.width != currentSessionInfo!!.width || info.height != currentSessionInfo!!.height)
|
|
||||||
) {
|
|
||||||
// Flex display: rebuild decoder on size change
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
TAG,
|
||||||
"onVideoSizeChanged(): rebuild decoder ${currentSessionInfo!!.width}x${currentSessionInfo!!.height} → ${info.width}x${info.height}",
|
"onVideoSizeChanged(): decoder does not support ${info.width}x${info.height} for $mime",
|
||||||
)
|
)
|
||||||
currentSessionInfo = info
|
handleUnsupportedSize(info.width, info.height, mime)
|
||||||
createOrReplaceDecoder(info)
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controller.rebuildDecoderForSize(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,150 +230,98 @@ object NativeCoreFacade {
|
|||||||
* Cleans up decoders and resets state.
|
* Cleans up decoders and resets state.
|
||||||
*/
|
*/
|
||||||
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
||||||
releaseAllDecoders()
|
controller.releaseAll()
|
||||||
synchronized(bootstrapLock) {
|
|
||||||
bootstrapPackets.clear()
|
|
||||||
latestConfigPacket = null
|
|
||||||
}
|
|
||||||
scrcpyRef = null
|
scrcpyRef = null
|
||||||
currentSessionInfo = null
|
|
||||||
recordingSurfaceAttached = false
|
recordingSurfaceAttached = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "NativeCoreFacade"
|
private fun cacheAndFeed(packet: Scrcpy.Session.VideoPacket) {
|
||||||
private const val MAX_BOOTSTRAP_PACKETS = 90
|
packetCount += 1
|
||||||
|
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||||
private data class CachedPacket(
|
Log.i(
|
||||||
val data: ByteArray,
|
TAG,
|
||||||
val ptsUs: Long,
|
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} usable=${controller.isDecoderUsable()}",
|
||||||
val isConfig: Boolean,
|
)
|
||||||
val isKeyFrame: Boolean,
|
|
||||||
) {
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
|
|
||||||
other as CachedPacket
|
|
||||||
|
|
||||||
if (ptsUs != other.ptsUs) return false
|
|
||||||
if (isConfig != other.isConfig) return false
|
|
||||||
if (isKeyFrame != other.isKeyFrame) return false
|
|
||||||
if (!data.contentEquals(other.data)) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
val usable = controller.feed(packet)
|
||||||
override fun hashCode(): Int {
|
if (!usable && !isRestarting) {
|
||||||
var result = ptsUs.hashCode()
|
Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
|
||||||
result = 31 * result + isConfig.hashCode()
|
handleDecoderError()
|
||||||
result = 31 * result + isKeyFrame.hashCode()
|
|
||||||
result = 31 * result + data.contentHashCode()
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or replace the active decoder bound to [surface] for [session].
|
* Handle the case where the local decoder does not support the target resolution.
|
||||||
*
|
*
|
||||||
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
* When "downsize on decode error" is enabled (default), restarts the session with a
|
||||||
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
* lower `max_size` derived from [DecoderCapabilities.maxSupportedSize]. Otherwise,
|
||||||
* registered listeners on the main thread.
|
* shows an error snackbar and leaves the decoder uncreated (black screen, no crash).
|
||||||
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
|
||||||
* faster playback startup.
|
|
||||||
*/
|
*/
|
||||||
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
|
private suspend fun handleUnsupportedSize(width: Int, height: Int, mime: String) {
|
||||||
val surface = renderer.getDecoderSurface()
|
val downsizeEnabled = Storage.appSettings.bundleState.value.downsizeOnDecodeError
|
||||||
decoder?.release()
|
if (!downsizeEnabled) {
|
||||||
decoder = null
|
AppRuntime.snackbar(R.string.vm_decoder_init_failed, "${width}x${height}")
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"createOrReplaceDecoder(): " +
|
|
||||||
"codec=${session.codec?.string ?: "null"}, " +
|
|
||||||
"size=${session.width}x${session.height}, " +
|
|
||||||
"persistent=true",
|
|
||||||
)
|
|
||||||
val newDecoder = AnnexBDecoder(
|
|
||||||
width = session.width,
|
|
||||||
height = session.height,
|
|
||||||
outputSurface = surface,
|
|
||||||
mimeType = when (session.codec) {
|
|
||||||
Codec.H264 -> "video/avc"
|
|
||||||
Codec.H265 -> "video/hevc"
|
|
||||||
Codec.AV1 -> "video/av01"
|
|
||||||
else -> "video/avc"
|
|
||||||
},
|
|
||||||
onOutputSizeChanged = { width, height ->
|
|
||||||
val current = currentSessionInfo
|
|
||||||
if (current == null || (current.width == width && current.height == height)) {
|
|
||||||
return@AnnexBDecoder
|
|
||||||
}
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
|
|
||||||
)
|
|
||||||
currentSessionInfo = current.copy(width = width, height = height)
|
|
||||||
mainHandler.post {
|
|
||||||
videoSizeListeners.forEach { listener ->
|
|
||||||
runCatching { listener(width, height) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFpsUpdated = { fps ->
|
|
||||||
mainHandler.post {
|
|
||||||
videoFpsListeners.forEach { listener ->
|
|
||||||
runCatching { listener(fps) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
decoder = newDecoder
|
|
||||||
replayBootstrapPackets(newDecoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
|
||||||
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
|
|
||||||
if (snapshot.isEmpty()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
|
val maxSize = DecoderCapabilities.maxSupportedSize(mime)
|
||||||
snapshot.forEach { packet ->
|
Log.i(TAG, "handleUnsupportedSize(): downgrading to max_size=$maxSize")
|
||||||
|
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 {
|
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) {
|
restartSessionWith(newOptions)
|
||||||
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()
|
* Stop the current session and start a new one with [options].
|
||||||
latestConfigPacket?.let { bootstrapPackets.addLast(it) }
|
*
|
||||||
bootstrapPackets.addLast(cached)
|
* Must be called while holding [sessionLifecycleMutex]. The [isRestarting] flag
|
||||||
return
|
* 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) {
|
private const val TAG = "NativeCoreFacade"
|
||||||
bootstrapPackets.removeFirst()
|
|
||||||
}
|
|
||||||
bootstrapPackets.addLast(cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseAllDecoders() {
|
|
||||||
runCatching { decoder?.release() }
|
|
||||||
decoder = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
class StreamActivity: FragmentActivity() {
|
class StreamActivity: FragmentActivity() {
|
||||||
private val basicPip = BasicPictureInPicture(this, ContextCompat.getMainExecutor(this))
|
private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
|
||||||
|
|
||||||
private val pipActionReceiver = PictureInPictureActionReceiver()
|
private val pipActionReceiver = PictureInPictureActionReceiver()
|
||||||
private var isPipActionReceiverRegistered = false
|
private var isPipActionReceiverRegistered = false
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
|
|||||||
val p = port ?: old.port
|
val p = port ?: old.port
|
||||||
listOf("$host:$p")
|
listOf("$host:$p")
|
||||||
}
|
}
|
||||||
|
|
||||||
updateById -> old.addresses
|
updateById -> old.addresses
|
||||||
|
|
||||||
newPort != null -> {
|
newPort != null -> {
|
||||||
|
|||||||
@@ -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 {
|
private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey {
|
||||||
val kf = KeyFactory.getInstance("RSA")
|
val kf = KeyFactory.getInstance("RSA")
|
||||||
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
|
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
|
||||||
return if (key is RSAPrivateCrtKey) key
|
return key as? RSAPrivateCrtKey ?: kf.generatePrivate(parsePkcs8PrivateKey(der))
|
||||||
else kf.generatePrivate(parsePkcs8PrivateKey(der))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parsePublicKey(content: String): PublicKey {
|
private fun parsePublicKey(content: String): PublicKey {
|
||||||
@@ -366,7 +365,25 @@ internal object DirectAdbTransport {
|
|||||||
private data class PemBlock(
|
private data class PemBlock(
|
||||||
val label: String,
|
val label: String,
|
||||||
val bytes: ByteArray,
|
val bytes: ByteArray,
|
||||||
)
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as PemBlock
|
||||||
|
|
||||||
|
if (label != other.label) return false
|
||||||
|
if (!bytes.contentEquals(other.bytes)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = label.hashCode()
|
||||||
|
result = 31 * result + bytes.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun readPem(content: String): PemBlock? {
|
private fun readPem(content: String): PemBlock? {
|
||||||
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
|
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
|
||||||
@@ -661,9 +678,13 @@ internal class DirectAdbConnection(
|
|||||||
fun openStream(service: String): AdbSocketStream {
|
fun openStream(service: String): AdbSocketStream {
|
||||||
val localId = nextLocalId.getAndIncrement()
|
val localId = nextLocalId.getAndIncrement()
|
||||||
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
|
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
|
||||||
val stream = AdbSocketStream(localId, { command, arg0, arg1, data ->
|
val stream = AdbSocketStream(
|
||||||
|
localId,
|
||||||
|
{ command, arg0, arg1, data ->
|
||||||
sendMsg(command, arg0, arg1, data)
|
sendMsg(command, arg0, arg1, data)
|
||||||
}, flowControlWindow)
|
},
|
||||||
|
flowControlWindow,
|
||||||
|
)
|
||||||
streams[localId] = stream
|
streams[localId] = stream
|
||||||
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
|
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
|
||||||
try {
|
try {
|
||||||
@@ -979,8 +1000,9 @@ class AdbSocketStream(
|
|||||||
private val queue = LinkedBlockingQueue<Any>()
|
private val queue = LinkedBlockingQueue<Any>()
|
||||||
|
|
||||||
// need notifyAll() / wait()
|
// need notifyAll() / wait()
|
||||||
private val writeLock = java.lang.Object()
|
private val writeLock = Object()
|
||||||
@Volatile private var inflightWrites = 0
|
@Volatile
|
||||||
|
private var inflightWrites = 0
|
||||||
|
|
||||||
private object EndOfStreamMarker
|
private object EndOfStreamMarker
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import android.util.Log
|
|||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AnnexBDecoder
|
* MediaCodecVideoDecoder
|
||||||
*
|
*
|
||||||
* Purpose:
|
* Purpose:
|
||||||
* - Wraps Android MediaCodec for Annex-B framed codecs (H.264/H.265/AV1).
|
* - Wraps Android MediaCodec for all video codecs (H.264/H.265/AV1/VP8/VP9).
|
||||||
* - Handles critical startup packets (config/keyframes) and provides callbacks
|
* - Handles critical startup packets (config/keyframes) and provides callbacks
|
||||||
* for output size changes and FPS updates.
|
* for output size changes and FPS updates.
|
||||||
|
* - Codecs without Annex-B config packets (VP8/VP9) work natively: no CSD is
|
||||||
|
* required for MediaCodec initialization.
|
||||||
*
|
*
|
||||||
* Threading / safety:
|
* Threading / safety:
|
||||||
* - Public methods are synchronized to allow calls from multiple threads
|
* - Public methods are synchronized to allow calls from multiple threads
|
||||||
* (packet producer vs. teardown). Internally, MediaCodec callbacks and
|
* (packet producer vs. teardown). Internally, MediaCodec callbacks and
|
||||||
* buffer queues are used on the calling thread.
|
* buffer queues are used on the calling thread.
|
||||||
*/
|
*/
|
||||||
class AnnexBDecoder(
|
class MediaCodecVideoDecoder(
|
||||||
width: Int,
|
width: Int,
|
||||||
height: Int,
|
height: Int,
|
||||||
outputSurface: Surface,
|
outputSurface: Surface,
|
||||||
@@ -41,9 +43,23 @@ class AnnexBDecoder(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var released = false
|
private var released = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when the codec reports an unrecoverable error (either during configure/start
|
||||||
|
* or at runtime via [MediaCodec.CodecException] / [IllegalStateException]).
|
||||||
|
*
|
||||||
|
* Once non-null, [isUsable] returns false and subsequent [feedAnnexB] calls are no-ops.
|
||||||
|
* The controller ([VideoDecoderController]) checks this to trigger session restart.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
internal var codecError: Throwable? = null
|
||||||
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!outputSurface.isValid) {
|
if (!outputSurface.isValid) {
|
||||||
throw IllegalStateException("Cannot initialize decoder: output surface is not valid")
|
throw DecoderException(
|
||||||
|
mimeType, width, height,
|
||||||
|
IllegalStateException("Output surface is not valid"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||||
if (sps != null) {
|
if (sps != null) {
|
||||||
@@ -52,8 +68,14 @@ class AnnexBDecoder(
|
|||||||
if (pps != null) {
|
if (pps != null) {
|
||||||
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
|
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
codec.configure(format, outputSurface, null, 0)
|
codec.configure(format, outputSurface, null, 0)
|
||||||
codec.start()
|
codec.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { codec.release() }
|
||||||
|
codecError = e
|
||||||
|
throw DecoderException(mimeType, width, height, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +90,7 @@ class AnnexBDecoder(
|
|||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
|
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
|
||||||
if (released) {
|
if (released || codecError != null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -107,9 +129,13 @@ class AnnexBDecoder(
|
|||||||
}
|
}
|
||||||
drainOutput()
|
drainOutput()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.w(
|
// MediaCodec.CodecException and IllegalStateException indicate the codec is in an
|
||||||
|
// unrecoverable error state (e.g. MTK OMX fatal error after surface conflicts).
|
||||||
|
// Record the error so isUsable() returns false and the controller can react.
|
||||||
|
codecError = it
|
||||||
|
Log.e(
|
||||||
TAG,
|
TAG,
|
||||||
"feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
|
"feed failed (codec marked unusable): mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -145,6 +171,12 @@ class AnnexBDecoder(
|
|||||||
runCatching { codec.release() }
|
runCatching { codec.release() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the decoder is still alive and the codec has not reported
|
||||||
|
* an unrecoverable error.
|
||||||
|
*/
|
||||||
|
fun isUsable(): Boolean = !released && codecError == null
|
||||||
|
|
||||||
private fun drainOutput() {
|
private fun drainOutput() {
|
||||||
while (true) {
|
while (true) {
|
||||||
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
|
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
|
||||||
@@ -202,7 +234,7 @@ class AnnexBDecoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AnnexBDecoder"
|
private const val TAG = "MediaCodecVideoDecoder"
|
||||||
private const val MIME_AVC = "video/avc"
|
private const val MIME_AVC = "video/avc"
|
||||||
private const val INPUT_TIMEOUT_US = 10_000L
|
private const val INPUT_TIMEOUT_US = 10_000L
|
||||||
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L
|
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L
|
||||||
@@ -62,6 +62,75 @@ class PersistentVideoRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically tear down and recreate the decoder-side surface (SurfaceTexture + OES texture
|
||||||
|
* + Surface), returning a fresh [Surface] for a new MediaCodec to render into.
|
||||||
|
*
|
||||||
|
* This is needed because some OMX decoders (notably MTK) fail when a new codec is configured
|
||||||
|
* against a surface that still has pending buffers from a previously released codec.
|
||||||
|
* By recreating the underlying SurfaceTexture + texture, the new codec gets a clean buffer
|
||||||
|
* queue with no leftover state.
|
||||||
|
*
|
||||||
|
* - Must be called after the old decoder has been released, so no producer is writing to
|
||||||
|
* the old surface at the same time.
|
||||||
|
* - Runs on the renderer HandlerThread (via [handler]) so it is serialized with [drawFrame].
|
||||||
|
* - EGL context, display/record EGL surfaces, and the shader program are preserved; only
|
||||||
|
* `oesTextureId` / `decoderSurfaceTexture` / `decoderSurface` are recreated.
|
||||||
|
*/
|
||||||
|
fun recreateDecoderSurface(): Surface {
|
||||||
|
ensureInitialized()
|
||||||
|
val latch = java.util.concurrent.CountDownLatch(1)
|
||||||
|
var result: Surface? = null
|
||||||
|
handler.post {
|
||||||
|
try {
|
||||||
|
if (released) {
|
||||||
|
error("renderer already released")
|
||||||
|
}
|
||||||
|
recreateDecoderSurfaceLocked()
|
||||||
|
result = decoderSurface
|
||||||
|
} finally {
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latch.await()
|
||||||
|
return result ?: error("failed to recreate decoder surface")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recreateDecoderSurfaceLocked() {
|
||||||
|
Log.i(tag, "recreateDecoderSurfaceLocked(): rebuilding decoder surface")
|
||||||
|
|
||||||
|
// Tear down old decoder-side resources. drawFrame() won't run concurrently
|
||||||
|
// because we're on the same HandlerThread.
|
||||||
|
runCatching { decoderSurface?.release() }
|
||||||
|
decoderSurface = null
|
||||||
|
runCatching { decoderSurfaceTexture?.release() }
|
||||||
|
decoderSurfaceTexture = null
|
||||||
|
if (oesTextureId != 0) {
|
||||||
|
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
|
||||||
|
oesTextureId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate from scratch (mirrors initializeLocked lines 243-259)
|
||||||
|
oesTextureId = createExternalTexture()
|
||||||
|
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
|
||||||
|
setOnFrameAvailableListener(
|
||||||
|
{
|
||||||
|
val n = frameAvailableCount.incrementAndGet()
|
||||||
|
if (n == 1L || n % 120L == 0L) {
|
||||||
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
drawFrame()
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decoderSurface = Surface(decoderSurfaceTexture)
|
||||||
|
Log.i(tag, "recreateDecoderSurfaceLocked(): decoder surface rebuilt, texture=$oesTextureId")
|
||||||
|
}
|
||||||
|
|
||||||
fun attachDisplaySurface(surface: Surface) {
|
fun attachDisplaySurface(surface: Surface) {
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
val newId = System.identityHashCode(surface)
|
val newId = System.identityHashCode(surface)
|
||||||
|
|||||||
@@ -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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -59,7 +62,7 @@ fun FileManagerScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
|
||||||
val pathStack by viewModel.pathStack.collectAsState()
|
val breadcrumbState by viewModel.breadcrumbState.collectAsState()
|
||||||
val currentPath by viewModel.currentPath.collectAsState()
|
val currentPath by viewModel.currentPath.collectAsState()
|
||||||
val cachedEntries by viewModel.cachedEntries.collectAsState()
|
val cachedEntries by viewModel.cachedEntries.collectAsState()
|
||||||
val loading by viewModel.loading.collectAsState()
|
val loading by viewModel.loading.collectAsState()
|
||||||
@@ -117,7 +120,7 @@ fun FileManagerScreen(
|
|||||||
if (pendingTreeDownload != null) treeLauncher.launch(null)
|
if (pendingTreeDownload != null) treeLauncher.launch(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(pathStack.size) {
|
DisposableEffect(canNavigateUp) {
|
||||||
onCanNavigateUpChange(canNavigateUp)
|
onCanNavigateUpChange(canNavigateUp)
|
||||||
onNavigateUpActionChange?.invoke(viewModel::navigateUp)
|
onNavigateUpActionChange?.invoke(viewModel::navigateUp)
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -144,7 +147,7 @@ fun FileManagerScreen(
|
|||||||
haptic.contextClick()
|
haptic.contextClick()
|
||||||
viewModel.navigateUp()
|
viewModel.navigateUp()
|
||||||
},
|
},
|
||||||
enabled = pathStack.size > 1,
|
enabled = canNavigateUp,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
@@ -153,30 +156,21 @@ fun FileManagerScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomContent = {
|
bottomContent = {
|
||||||
Text(
|
val breadcrumbItems = remember(breadcrumbState.stack) {
|
||||||
text = currentPath,
|
breadcrumbState.stack.map { path ->
|
||||||
modifier = Modifier
|
BreadcrumbItem(
|
||||||
.fillMaxWidth()
|
path = path,
|
||||||
.combinedClickable(
|
text = path.substringAfterLast('/').ifEmpty { "/" },
|
||||||
onClick = {
|
|
||||||
haptic.contextClick()
|
|
||||||
pathInput = currentPath
|
|
||||||
showPathDialog = true
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
// 自带 haptic
|
|
||||||
pathInput = currentPath
|
|
||||||
showPathDialog = true
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.padding(
|
}
|
||||||
start = UiSpacing.PageHorizontal,
|
}
|
||||||
end = UiSpacing.PageHorizontal,
|
BreadcrumbBar(
|
||||||
bottom = UiSpacing.Medium,
|
items = breadcrumbItems,
|
||||||
),
|
onItemClick = { index ->
|
||||||
softWrap = false,
|
haptic.contextClick()
|
||||||
overflow = TextOverflow.Ellipsis,
|
viewModel.jumpToPath(breadcrumbState.stack[index])
|
||||||
color = colorScheme.onSurfaceVariantSummary,
|
},
|
||||||
|
highlightIndex = breadcrumbState.highlightIndex,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
@@ -241,6 +235,13 @@ fun FileManagerScreen(
|
|||||||
OverlayIconDropdownMenu(
|
OverlayIconDropdownMenu(
|
||||||
entry = DropdownEntry(
|
entry = DropdownEntry(
|
||||||
items = listOf(
|
items = listOf(
|
||||||
|
DropdownItem(
|
||||||
|
text = stringResource(R.string.fm_goto_path),
|
||||||
|
onClick = {
|
||||||
|
pathInput = currentPath
|
||||||
|
showPathDialog = true
|
||||||
|
},
|
||||||
|
),
|
||||||
DropdownItem(
|
DropdownItem(
|
||||||
text = stringResource(R.string.fm_menu_create_folder),
|
text = stringResource(R.string.fm_menu_create_folder),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -708,3 +709,43 @@ private fun buildDetailsText(
|
|||||||
}
|
}
|
||||||
return details.toString()
|
return details.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class BreadcrumbItem(
|
||||||
|
val path: String,
|
||||||
|
val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BreadcrumbBar(
|
||||||
|
items: List<BreadcrumbItem>,
|
||||||
|
onItemClick: (Int) -> Unit,
|
||||||
|
highlightIndex: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = UiSpacing.Medium),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
if (index > 0) {
|
||||||
|
Text(
|
||||||
|
text = " / ",
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isHighlighted = index == highlightIndex
|
||||||
|
Text(
|
||||||
|
text = item.text,
|
||||||
|
color = if (isHighlighted) colorScheme.primary else colorScheme.onSurface,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = { onItemClick(index) })
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,17 +36,60 @@ internal class FileManagerViewModel: ViewModel() {
|
|||||||
)
|
)
|
||||||
private val asBundle = asBundleSync.value
|
private val asBundle = asBundleSync.value
|
||||||
|
|
||||||
private val _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH))
|
private val _currentPath = MutableStateFlow(INITIAL_REMOTE_PATH)
|
||||||
val pathStack: StateFlow<List<String>> = _pathStack.asStateFlow()
|
val currentPath: StateFlow<String> = _currentPath.asStateFlow()
|
||||||
val currentPath: StateFlow<String> = _pathStack
|
|
||||||
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH }
|
private val pathHistory = ArrayDeque<String>().apply { addLast(INITIAL_REMOTE_PATH) }
|
||||||
|
private val historyCapacity = 20
|
||||||
|
|
||||||
|
private fun addToHistory(path: String) {
|
||||||
|
pathHistory.remove(path)
|
||||||
|
pathHistory.addLast(path)
|
||||||
|
while (pathHistory.size > historyCapacity) pathHistory.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimHistory(path: String) {
|
||||||
|
pathHistory.retainAll { it == path || it.startsWith("$path/") || path.startsWith("$it/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun longestHistoricalPrefix(current: String): String {
|
||||||
|
val prefixMatch: (String) -> Boolean =
|
||||||
|
if (current == ROOT_REMOTE_PATH) {
|
||||||
|
{ true }
|
||||||
|
} else {
|
||||||
|
{ hist -> hist == current || hist.startsWith("$current/") }
|
||||||
|
}
|
||||||
|
var best = current
|
||||||
|
var bestLen = current.length
|
||||||
|
pathHistory.forEach { hist ->
|
||||||
|
if (hist.length > bestLen && prefixMatch(hist)) {
|
||||||
|
best = hist
|
||||||
|
bestLen = hist.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class BreadcrumbState(
|
||||||
|
val stack: List<String>,
|
||||||
|
val highlightIndex: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
val breadcrumbState: StateFlow<BreadcrumbState> = _currentPath
|
||||||
|
.map { current ->
|
||||||
|
val longest = longestHistoricalPrefix(current)
|
||||||
|
val stack = buildPathStack(longest)
|
||||||
|
val highlight = buildPathStack(current).lastIndex
|
||||||
|
BreadcrumbState(stack = stack, highlightIndex = highlight)
|
||||||
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.Eagerly,
|
SharingStarted.Eagerly,
|
||||||
INITIAL_REMOTE_PATH,
|
BreadcrumbState(buildPathStack(INITIAL_REMOTE_PATH), buildPathStack(INITIAL_REMOTE_PATH).lastIndex),
|
||||||
)
|
)
|
||||||
val canNavigateUp: StateFlow<Boolean> = _pathStack
|
|
||||||
.map { it.size > 1 }
|
val canNavigateUp: StateFlow<Boolean> = _currentPath
|
||||||
|
.map { it != ROOT_REMOTE_PATH }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.Eagerly,
|
SharingStarted.Eagerly,
|
||||||
@@ -145,21 +188,31 @@ internal class FileManagerViewModel: ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun navigateUp(): Boolean {
|
fun navigateUp(): Boolean {
|
||||||
val stack = _pathStack.value
|
val current = _currentPath.value
|
||||||
if (stack.size <= 1) return false
|
if (current == ROOT_REMOTE_PATH) return false
|
||||||
_pathStack.update { it.dropLast(1) }
|
val parent = current.substringBeforeLast('/', ROOT_REMOTE_PATH)
|
||||||
|
_currentPath.value = parent
|
||||||
|
trimHistory(parent)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun jumpToPath(rawPath: String) {
|
fun jumpToPath(rawPath: String) {
|
||||||
val normalized = normalizePath(rawPath)
|
val normalized = normalizePath(rawPath)
|
||||||
_pathStack.value = buildPathStack(normalized)
|
_currentPath.value = normalized
|
||||||
|
addToHistory(normalized)
|
||||||
|
trimHistory(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushPath(path: String) {
|
||||||
|
_currentPath.value = path
|
||||||
|
addToHistory(path)
|
||||||
|
trimHistory(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openEntry(entry: RemoteFileEntry) {
|
fun openEntry(entry: RemoteFileEntry) {
|
||||||
when {
|
when {
|
||||||
entry.isDirectory -> {
|
entry.isDirectory -> {
|
||||||
_pathStack.update { it + normalizePath(entry.fullPath) }
|
pushPath(normalizePath(entry.fullPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {
|
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {
|
||||||
|
|||||||
@@ -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(
|
SwitchPreference(
|
||||||
title = stringResource(R.string.pref_title_debug_info),
|
title = stringResource(R.string.pref_title_debug_info),
|
||||||
summary = stringResource(R.string.pref_summary_debug_info),
|
summary = stringResource(R.string.pref_summary_debug_info),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.pages
|
package io.github.miuzarte.scrcpyforandroid.pages
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -52,10 +53,10 @@ import kotlin.math.max
|
|||||||
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
|
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
|
||||||
private const val LOG_TAG = "TerminalScreen"
|
private const val LOG_TAG = "TerminalScreen"
|
||||||
|
|
||||||
private fun terminalFontFile(context: android.content.Context) =
|
private fun terminalFontFile(context: Context) =
|
||||||
File(context.filesDir, "terminal/font.ttf")
|
File(context.filesDir, "terminal/font.ttf")
|
||||||
|
|
||||||
private fun loadTerminalTypeface(context: android.content.Context): Typeface? {
|
private fun loadTerminalTypeface(context: Context): Typeface? {
|
||||||
val file = terminalFontFile(context)
|
val file = terminalFontFile(context)
|
||||||
if (!file.exists()) return null
|
if (!file.exists()) return null
|
||||||
return runCatching { Typeface.createFromFile(file) }.getOrNull()
|
return runCatching { Typeface.createFromFile(file) }.getOrNull()
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ data class ClientOptions(
|
|||||||
|
|
||||||
// --flex-display
|
// --flex-display
|
||||||
var flexDisplay: Boolean = false, // to server
|
var flexDisplay: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --ignore-video-encoder-constraints
|
||||||
|
var ignoreVideoEncoderConstraints: Boolean = false, // to server
|
||||||
|
|
||||||
|
// --no-terminal-title
|
||||||
|
// var updateTerminalTitle: Boolean = true,
|
||||||
) {
|
) {
|
||||||
enum class KeyInjectMode(val string: String) {
|
enum class KeyInjectMode(val string: String) {
|
||||||
MIXED("mixed"),
|
MIXED("mixed"),
|
||||||
@@ -633,6 +639,7 @@ data class ClientOptions(
|
|||||||
vdSystemDecorations = vdSystemDecorations,
|
vdSystemDecorations = vdSystemDecorations,
|
||||||
keepActive = keepActive,
|
keepActive = keepActive,
|
||||||
flexDisplay = flexDisplay,
|
flexDisplay = flexDisplay,
|
||||||
|
ignoreVideoEncoderConstraints = ignoreVideoEncoderConstraints,
|
||||||
list = list,
|
list = list,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ class Scrcpy(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "Scrcpy"
|
private const val TAG = "Scrcpy"
|
||||||
|
|
||||||
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0"
|
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.1"
|
||||||
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0"
|
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.1"
|
||||||
const val DEFAULT_SERVER_VERSION = "4.0"
|
const val DEFAULT_SERVER_VERSION = "4.1"
|
||||||
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
|
||||||
|
|
||||||
// Regex patterns for parsing server output
|
// Regex patterns for parsing server output
|
||||||
@@ -202,7 +202,7 @@ class Scrcpy(
|
|||||||
|
|
||||||
// Setup video consumer (notify NativeCoreFacade to setup decoders)
|
// Setup video consumer (notify NativeCoreFacade to setup decoders)
|
||||||
if (options.video) {
|
if (options.video) {
|
||||||
NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy)
|
NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup audio player
|
// Setup audio player
|
||||||
@@ -389,6 +389,10 @@ class Scrcpy(
|
|||||||
session.setClipboard(text, paste)
|
session.setClipboard(text, paste)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun scanFile(path: String) = withContext(Dispatchers.IO) {
|
||||||
|
session.scanFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun injectTouch(
|
suspend fun injectTouch(
|
||||||
action: Int,
|
action: Int,
|
||||||
pointerId: Long,
|
pointerId: Long,
|
||||||
@@ -1317,6 +1321,10 @@ class Scrcpy(
|
|||||||
withControlWriter("setClipboard") { setClipboard(text, paste) }
|
withControlWriter("setClipboard") { setClipboard(text, paste) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun scanFile(path: String) = mutex.withLock {
|
||||||
|
withControlWriter("scanFile") { scanFile(path) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun injectTouch(
|
suspend fun injectTouch(
|
||||||
action: Int,
|
action: Int,
|
||||||
pointerId: Long,
|
pointerId: Long,
|
||||||
@@ -1775,6 +1783,16 @@ class Scrcpy(
|
|||||||
output.flush()
|
output.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun scanFile(path: String) {
|
||||||
|
val bytes = path.toByteArray(Charsets.UTF_8)
|
||||||
|
require(bytes.size <= 256) { "scan file path is too long (max 256 bytes)" }
|
||||||
|
output.writeByte(TYPE_SCAN_FILE)
|
||||||
|
output.writeInt(bytes.size)
|
||||||
|
output.write(bytes)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
|
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
|
||||||
output.writeInt(x)
|
output.writeInt(x)
|
||||||
output.writeInt(y)
|
output.writeInt(y)
|
||||||
@@ -1836,6 +1854,7 @@ class Scrcpy(
|
|||||||
private const val TYPE_CAMERA_ZOOM_IN = 19
|
private const val TYPE_CAMERA_ZOOM_IN = 19
|
||||||
private const val TYPE_CAMERA_ZOOM_OUT = 20
|
private const val TYPE_CAMERA_ZOOM_OUT = 20
|
||||||
private const val TYPE_RESIZE_DISPLAY = 21
|
private const val TYPE_RESIZE_DISPLAY = 21
|
||||||
|
private const val TYPE_SCAN_FILE = 22
|
||||||
|
|
||||||
private const val SEQUENCE_INVALID = 0L
|
private const val SEQUENCE_INVALID = 0L
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ data class ServerParams(
|
|||||||
val vdSystemDecorations: Boolean,
|
val vdSystemDecorations: Boolean,
|
||||||
val keepActive: Boolean,
|
val keepActive: Boolean,
|
||||||
val flexDisplay: Boolean,
|
val flexDisplay: Boolean,
|
||||||
|
val ignoreVideoEncoderConstraints: Boolean,
|
||||||
|
|
||||||
val list: ListOptions,
|
val list: ListOptions,
|
||||||
) {
|
) {
|
||||||
@@ -268,6 +269,9 @@ data class ServerParams(
|
|||||||
if (flexDisplay) {
|
if (flexDisplay) {
|
||||||
cmd.add("flex_display=true")
|
cmd.add("flex_display=true")
|
||||||
}
|
}
|
||||||
|
if (ignoreVideoEncoderConstraints) {
|
||||||
|
cmd.add("ignore_video_encoder_constraints=true")
|
||||||
|
}
|
||||||
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
|
||||||
cmd.add("display_ime_policy=${displayImePolicy.string}")
|
cmd.add("display_ime_policy=${displayImePolicy.string}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,18 +82,21 @@ class Shared {
|
|||||||
enum class Codec(
|
enum class Codec(
|
||||||
val string: String,
|
val string: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
val mime: String,
|
||||||
val type: Type,
|
val type: Type,
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val isLossless: Boolean,
|
val isLossless: Boolean,
|
||||||
) {
|
) {
|
||||||
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
H264("h264", "H.264", "video/avc", Type.VIDEO, 0x68323634, false), // default, ignore when passing
|
||||||
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
|
H265("h265", "H.265", "video/hevc", Type.VIDEO, 0x68323635, false),
|
||||||
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
|
AV1("av1", "AV1", "video/av01", Type.VIDEO, 0x00617631, false),
|
||||||
|
VP8("vp8", "VP8", "video/x-vnd.on2.vp8", Type.VIDEO, 0x00767038, false),
|
||||||
|
VP9("vp9", "VP9", "video/x-vnd.on2.vp9", Type.VIDEO, 0x00767039, false),
|
||||||
|
|
||||||
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
OPUS("opus", "Opus", "audio/opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
|
||||||
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
|
AAC("aac", "AAC", "audio/mp4a-latm", Type.AUDIO, 0x00616163, false),
|
||||||
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
|
FLAC("flac", "FLAC", "audio/flac", Type.AUDIO, 0x666c6163, true),
|
||||||
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
|
RAW("raw", "RAW", "audio/raw", Type.AUDIO, 0x00726177, true); // wav raw
|
||||||
|
|
||||||
enum class Type { VIDEO, AUDIO }
|
enum class Type { VIDEO, AUDIO }
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
booleanPreferencesKey("low_latency"),
|
booleanPreferencesKey("low_latency"),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
val DOWNSIZE_ON_DECODE_ERROR = Pair(
|
||||||
|
booleanPreferencesKey("downsize_on_decode_error"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
val FULLSCREEN_DEBUG_INFO = Pair(
|
val FULLSCREEN_DEBUG_INFO = Pair(
|
||||||
booleanPreferencesKey("fullscreen_debug_info"),
|
booleanPreferencesKey("fullscreen_debug_info"),
|
||||||
false,
|
false,
|
||||||
@@ -318,6 +322,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
|
|
||||||
// Scrcpy
|
// Scrcpy
|
||||||
val lowLatency: Boolean,
|
val lowLatency: Boolean,
|
||||||
|
val downsizeOnDecodeError: Boolean,
|
||||||
val fullscreenDebugInfo: Boolean,
|
val fullscreenDebugInfo: Boolean,
|
||||||
val hideSimpleConfigItems: Boolean,
|
val hideSimpleConfigItems: Boolean,
|
||||||
val previewCardOnTop: Boolean,
|
val previewCardOnTop: Boolean,
|
||||||
@@ -383,6 +388,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
|
|
||||||
// Scrcpy
|
// Scrcpy
|
||||||
bundleField(LOW_LATENCY) { it.lowLatency },
|
bundleField(LOW_LATENCY) { it.lowLatency },
|
||||||
|
bundleField(DOWNSIZE_ON_DECODE_ERROR) { it.downsizeOnDecodeError },
|
||||||
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
|
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
|
||||||
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
|
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
|
||||||
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
|
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
|
||||||
@@ -449,6 +455,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
|
|||||||
|
|
||||||
// Scrcpy
|
// Scrcpy
|
||||||
lowLatency = preferences.read(LOW_LATENCY),
|
lowLatency = preferences.read(LOW_LATENCY),
|
||||||
|
downsizeOnDecodeError = preferences.read(DOWNSIZE_ON_DECODE_ERROR),
|
||||||
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
|
||||||
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
||||||
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),
|
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
booleanPreferencesKey("flex_display"),
|
booleanPreferencesKey("flex_display"),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
val IGNORE_VIDEO_ENCODER_CONSTRAINTS = Pair(
|
||||||
|
booleanPreferencesKey("ignore_video_encoder_constraints"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
fun defaultBundle() = Bundle(
|
fun defaultBundle() = Bundle(
|
||||||
crop = CROP.defaultValue,
|
crop = CROP.defaultValue,
|
||||||
@@ -351,6 +355,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
cameraTorch = CAMERA_TORCH.defaultValue,
|
cameraTorch = CAMERA_TORCH.defaultValue,
|
||||||
keepActive = KEEP_ACTIVE.defaultValue,
|
keepActive = KEEP_ACTIVE.defaultValue,
|
||||||
flexDisplay = FLEX_DISPLAY.defaultValue,
|
flexDisplay = FLEX_DISPLAY.defaultValue,
|
||||||
|
ignoreVideoEncoderConstraints = IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +427,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
val cameraTorch: Boolean,
|
val cameraTorch: Boolean,
|
||||||
val keepActive: Boolean,
|
val keepActive: Boolean,
|
||||||
val flexDisplay: Boolean,
|
val flexDisplay: Boolean,
|
||||||
|
val ignoreVideoEncoderConstraints: Boolean,
|
||||||
): Parcelable {
|
): Parcelable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +497,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
bundleField(CAMERA_TORCH) { it.cameraTorch },
|
bundleField(CAMERA_TORCH) { it.cameraTorch },
|
||||||
bundleField(KEEP_ACTIVE) { it.keepActive },
|
bundleField(KEEP_ACTIVE) { it.keepActive },
|
||||||
bundleField(FLEX_DISPLAY) { it.flexDisplay },
|
bundleField(FLEX_DISPLAY) { it.flexDisplay },
|
||||||
|
bundleField(IGNORE_VIDEO_ENCODER_CONSTRAINTS) { it.ignoreVideoEncoderConstraints },
|
||||||
)
|
)
|
||||||
|
|
||||||
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||||
@@ -562,6 +569,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
cameraTorch = preferences.read(CAMERA_TORCH),
|
cameraTorch = preferences.read(CAMERA_TORCH),
|
||||||
keepActive = preferences.read(KEEP_ACTIVE),
|
keepActive = preferences.read(KEEP_ACTIVE),
|
||||||
flexDisplay = preferences.read(FLEX_DISPLAY),
|
flexDisplay = preferences.read(FLEX_DISPLAY),
|
||||||
|
ignoreVideoEncoderConstraints = preferences.read(IGNORE_VIDEO_ENCODER_CONSTRAINTS),
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
|
||||||
@@ -645,6 +653,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
|
|||||||
cameraTorch = bundle.cameraTorch,
|
cameraTorch = bundle.cameraTorch,
|
||||||
keepActive = bundle.keepActive,
|
keepActive = bundle.keepActive,
|
||||||
flexDisplay = bundle.flexDisplay,
|
flexDisplay = bundle.flexDisplay,
|
||||||
|
ignoreVideoEncoderConstraints = bundle.ignoreVideoEncoderConstraints,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +725,7 @@ internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
|
|||||||
.put("cameraTorch", bundle.cameraTorch)
|
.put("cameraTorch", bundle.cameraTorch)
|
||||||
.put("keepActive", bundle.keepActive)
|
.put("keepActive", bundle.keepActive)
|
||||||
.put("flexDisplay", bundle.flexDisplay)
|
.put("flexDisplay", bundle.flexDisplay)
|
||||||
|
.put("ignoreVideoEncoderConstraints", bundle.ignoreVideoEncoderConstraints)
|
||||||
|
|
||||||
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
|
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
|
||||||
val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
|
val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
|
||||||
@@ -984,6 +994,10 @@ internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle
|
|||||||
"flexDisplay",
|
"flexDisplay",
|
||||||
ScrcpyOptions.FLEX_DISPLAY.defaultValue,
|
ScrcpyOptions.FLEX_DISPLAY.defaultValue,
|
||||||
),
|
),
|
||||||
|
ignoreVideoEncoderConstraints = json.optBooleanOrDefault(
|
||||||
|
"ignoreVideoEncoderConstraints",
|
||||||
|
ScrcpyOptions.IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.storage
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -33,7 +34,7 @@ class ScrcpyProfiles(context: Context): Settings(context, "ScrcpyProfiles") {
|
|||||||
|
|
||||||
val state: StateFlow<State> = createBundleState(::stateFromPreferences)
|
val state: StateFlow<State> = createBundleState(::stateFromPreferences)
|
||||||
|
|
||||||
private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State {
|
private fun stateFromPreferences(preferences: Preferences): State {
|
||||||
val raw = preferences.read(PROFILES_JSON)
|
val raw = preferences.read(PROFILES_JSON)
|
||||||
return normalizeState(
|
return normalizeState(
|
||||||
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()),
|
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()),
|
||||||
|
|||||||
@@ -63,14 +63,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
|||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.confirm
|
import io.github.miuzarte.scrcpyforandroid.ui.confirm
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
import io.github.miuzarte.scrcpyforandroid.ui.contextClick
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import top.yukonga.miuix.kmp.basic.*
|
import top.yukonga.miuix.kmp.basic.*
|
||||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
import top.yukonga.miuix.kmp.icon.extended.AddCircle
|
import top.yukonga.miuix.kmp.icon.extended.AddCircle
|
||||||
|
|||||||
@@ -168,6 +168,8 @@
|
|||||||
<string name="section_screen_mirroring">投屏</string>
|
<string name="section_screen_mirroring">投屏</string>
|
||||||
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</string>
|
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</string>
|
||||||
<string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string>
|
<string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string>
|
||||||
|
<string name="pref_title_downsize_on_decode_error">解码出错时自动降级</string>
|
||||||
|
<string name="pref_summary_downsize_on_decode_error">当本机解码器不支持当前视频分辨率时,自动以较低的 max_size 重启会话,关闭后仅提示错误</string>
|
||||||
<string name="pref_title_debug_info">启用调试信息</string>
|
<string name="pref_title_debug_info">启用调试信息</string>
|
||||||
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
|
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
|
||||||
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
|
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
|
||||||
@@ -534,6 +536,7 @@
|
|||||||
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
|
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
|
||||||
<string name="scrcpyopt_no_cleanup_desc">默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
|
<string name="scrcpyopt_no_cleanup_desc">默认情况下,scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
|
||||||
<string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string>
|
<string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string>
|
||||||
|
<string name="scrcpyopt_ignore_video_encoder_constraints">忽略视频编码器约束</string>
|
||||||
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
|
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
|
||||||
<string name="scrcpyopt_native">本机</string>
|
<string name="scrcpyopt_native">本机</string>
|
||||||
<string name="scrcpyopt_log_level">日志等级</string>
|
<string name="scrcpyopt_log_level">日志等级</string>
|
||||||
@@ -574,6 +577,9 @@
|
|||||||
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
|
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
|
||||||
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
|
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
|
||||||
<string name="vm_session_disconnected">Scrcpy 连接已断开</string>
|
<string name="vm_session_disconnected">Scrcpy 连接已断开</string>
|
||||||
|
<string name="vm_decoder_unsupported_size">设备解码器不支持 %1$dx%2$d,正在降级为 max_size=%3$d 并重启</string>
|
||||||
|
<string name="vm_decoder_init_failed">解码器初始化失败:%1$s</string>
|
||||||
|
<string name="vm_decoder_error_restarting">解码器错误,正在重启会话</string>
|
||||||
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
|
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
|
||||||
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
|
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
|
||||||
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>
|
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>
|
||||||
|
|||||||
@@ -169,6 +169,8 @@
|
|||||||
<string name="section_screen_mirroring">Screen mirroring</string>
|
<string name="section_screen_mirroring">Screen mirroring</string>
|
||||||
<string name="pref_title_low_latency_audio">Low latency audio (experimental)</string>
|
<string name="pref_title_low_latency_audio">Low latency audio (experimental)</string>
|
||||||
<string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string>
|
<string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string>
|
||||||
|
<string name="pref_title_downsize_on_decode_error">Downscale on decoder error</string>
|
||||||
|
<string name="pref_summary_downsize_on_decode_error">When the local decoder cannot handle the video resolution, automatically restart the session with a lower max_size. Disable to show an error instead</string>
|
||||||
<string name="pref_title_debug_info">Enable debug info</string>
|
<string name="pref_title_debug_info">Enable debug info</string>
|
||||||
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
|
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
|
||||||
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
|
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
|
||||||
@@ -372,14 +374,14 @@
|
|||||||
<string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string>
|
<string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string>
|
||||||
<string name="password_agree">Agree</string>
|
<string name="password_agree">Agree</string>
|
||||||
<string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string>
|
<string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string>
|
||||||
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned.</string>
|
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned</string>
|
||||||
<string name="password_continue_disable">Continue to disable</string>
|
<string name="password_continue_disable">Continue to disable</string>
|
||||||
<string name="password_delete_confirm">Delete password</string>
|
<string name="password_delete_confirm">Delete password</string>
|
||||||
<string name="password_delete_msg">Will delete %1$s</string>
|
<string name="password_delete_msg">Will delete %1$s</string>
|
||||||
<string name="password_default_name">Password %1$d</string>
|
<string name="password_default_name">Password %1$d</string>
|
||||||
<string name="password_cannot_be_empty">Password cannot be empty</string>
|
<string name="password_cannot_be_empty">Password cannot be empty</string>
|
||||||
<string name="password_require_auth">Require authentication when filling passwords</string>
|
<string name="password_require_auth">Require authentication when filling passwords</string>
|
||||||
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned.</string>
|
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned</string>
|
||||||
<string name="password_no_auth_capability">Current device has no authentication capability</string>
|
<string name="password_no_auth_capability">Current device has no authentication capability</string>
|
||||||
<string name="password_or_menu_hint">Or in the top-right menu</string>
|
<string name="password_or_menu_hint">Or in the top-right menu</string>
|
||||||
<string name="password_status_invalidated">Invalidated</string>
|
<string name="password_status_invalidated">Invalidated</string>
|
||||||
@@ -388,7 +390,7 @@
|
|||||||
<string name="password_status_burned">Authenticated when created (burned)</string>
|
<string name="password_status_burned">Authenticated when created (burned)</string>
|
||||||
<string name="password_rename">Rename password</string>
|
<string name="password_rename">Rename password</string>
|
||||||
<string name="password_lockscreen_label">Lockscreen password</string>
|
<string name="password_lockscreen_label">Lockscreen password</string>
|
||||||
<string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully.</string>
|
<string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully</string>
|
||||||
|
|
||||||
<!-- File Manager -->
|
<!-- File Manager -->
|
||||||
<string name="fm_cd_parent">Parent directory</string>
|
<string name="fm_cd_parent">Parent directory</string>
|
||||||
@@ -526,15 +528,16 @@
|
|||||||
<string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string>
|
<string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string>
|
||||||
<string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string>
|
<string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string>
|
||||||
<string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string>
|
<string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string>
|
||||||
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior.</string>
|
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior</string>
|
||||||
<string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string>
|
<string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string>
|
||||||
<string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string>
|
<string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string>
|
||||||
<string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string>
|
<string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string>
|
||||||
<string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string>
|
<string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string>
|
||||||
<string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string>
|
<string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string>
|
||||||
<string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string>
|
<string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string>
|
||||||
<string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation.</string>
|
<string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation</string>
|
||||||
<string name="scrcpyopt_flex_display">Automatically resize to match the screen</string>
|
<string name="scrcpyopt_flex_display">Automatically resize to match the screen</string>
|
||||||
|
<string name="scrcpyopt_ignore_video_encoder_constraints">Ignore video encoder constraints</string>
|
||||||
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
|
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
|
||||||
<string name="scrcpyopt_native">Native</string>
|
<string name="scrcpyopt_native">Native</string>
|
||||||
<string name="scrcpyopt_log_level">Log level</string>
|
<string name="scrcpyopt_log_level">Log level</string>
|
||||||
@@ -575,6 +578,9 @@
|
|||||||
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
|
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
|
||||||
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
|
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
|
||||||
<string name="vm_session_disconnected">Scrcpy session disconnected</string>
|
<string name="vm_session_disconnected">Scrcpy session disconnected</string>
|
||||||
|
<string name="vm_decoder_unsupported_size">Device decoder does not support %1$dx%2$d, downgrading to max_size=%3$d and restarting</string>
|
||||||
|
<string name="vm_decoder_init_failed">Decoder initialization failed: %1$s</string>
|
||||||
|
<string name="vm_decoder_error_restarting">Decoder error, restarting session</string>
|
||||||
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
|
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
|
||||||
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
|
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
|
||||||
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>
|
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
<!-- Status bar color. -->
|
<!-- Status bar color. -->
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<!-- Customize your theme here. -->
|
<!-- Customize your theme here. -->
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -15,7 +15,7 @@ androidxNavigation3 = "1.1.4"
|
|||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
androidxJunit = "1.3.0"
|
androidxJunit = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
miuix = "0.9.0"
|
miuix = "0.9.3"
|
||||||
backdrop = "1.0.6"
|
backdrop = "1.0.6"
|
||||||
material = "1.14.0"
|
material = "1.14.0"
|
||||||
reorderable = "3.1.0"
|
reorderable = "3.1.0"
|
||||||
|
|||||||
@@ -7,5 +7,15 @@
|
|||||||
"git-submodules": {
|
"git-submodules": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"schedule": ["before 6am on Monday"]
|
"schedule": [
|
||||||
|
"before 6am on Monday"
|
||||||
|
],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": [
|
||||||
|
"miuix"
|
||||||
|
],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Submodule submodule/miuix updated: b728c2d9ac...fd5362ce88
Reference in New Issue
Block a user