refactor: break down NativeCoreFacade
- 捕获解码器错误 - 旋转屏幕重建不复用旧 buffer
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -47,3 +47,5 @@ abandoned_assets/
|
||||
|
||||
# scrcpy-server binary (downloaded by Gradle during build)
|
||||
app/src/main/assets/bin/scrcpy-server-v*
|
||||
|
||||
docs/
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Change Log
|
||||
|
||||
## 0.4.5
|
||||
|
||||
- 重构: 改了好多不知道怎么写,总之修复了一些已知问题
|
||||
|
||||
## 0.4.4
|
||||
|
||||
- 修复: 画中画初始化空指针
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- 改进: 对控制通道增加流控选项
|
||||
|
||||
@@ -60,7 +60,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 35
|
||||
versionName = "0.4.4"
|
||||
versionName = "0.4.5_pre1"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
|
||||
@@ -1,58 +1,48 @@
|
||||
package io.github.miuzarte.scrcpyforandroid
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.VideoDecoderController
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
/**
|
||||
* Facade that centralizes video rendering.
|
||||
* Facade that centralizes video rendering and decoder management.
|
||||
*
|
||||
* Provides helpers for:
|
||||
* - Surface/Decoder management for video rendering
|
||||
* - Video size and FPS monitoring
|
||||
* Acts as a thin front-end over [VideoDecoderController] (decoder lifecycle, bootstrap
|
||||
* cache, error detection) and [PersistentVideoRenderer] (EGL / surface management).
|
||||
*
|
||||
* The facade owns the session lifecycle mutex and coordinates surface attach/detach
|
||||
* with decoder creation. It also publishes video size / FPS to listeners.
|
||||
*/
|
||||
object NativeCoreFacade {
|
||||
private val sessionLifecycleMutex = Mutex()
|
||||
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val renderer = PersistentVideoRenderer()
|
||||
private val controller = VideoDecoderController(renderer)
|
||||
|
||||
@Volatile
|
||||
private var activeSurfaceId: Int? = null
|
||||
private var decoder: AnnexBDecoder? = null
|
||||
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
||||
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val bootstrapLock = Any()
|
||||
private val bootstrapPackets = ArrayDeque<CachedPacket>()
|
||||
|
||||
@Volatile
|
||||
private var recordingSurfaceAttached = false
|
||||
|
||||
@Volatile
|
||||
private var latestConfigPacket: CachedPacket? = null
|
||||
private var packetCount: Long = 0
|
||||
|
||||
@Volatile
|
||||
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
|
||||
|
||||
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
|
||||
@Volatile
|
||||
private var scrcpyRef: Scrcpy? = null
|
||||
|
||||
@Volatile
|
||||
private var packetCount: Long = 0
|
||||
|
||||
suspend fun close() {
|
||||
sessionLifecycleMutex.withLock {
|
||||
releaseAllDecoders()
|
||||
controller.releaseAll()
|
||||
renderer.release()
|
||||
}
|
||||
}
|
||||
@@ -71,30 +61,20 @@ object NativeCoreFacade {
|
||||
return
|
||||
}
|
||||
val newId = System.identityHashCode(surface)
|
||||
if (activeSurfaceId == newId && decoder != null) {
|
||||
if (activeSurfaceId == newId && controller.isDecoderUsable()) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
|
||||
activeSurfaceId = newId
|
||||
renderer.attachDisplaySurface(surface)
|
||||
val session = currentSessionInfo ?: return
|
||||
val currentDecoder = decoder
|
||||
if (currentDecoder != null) {
|
||||
controller.attachDisplaySurface(surface)
|
||||
val session = controller.getCurrentSessionInfo() ?: return
|
||||
if (controller.isDecoderUsable()) {
|
||||
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
|
||||
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface())
|
||||
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
|
||||
if (switched) {
|
||||
if (controller.trySwitchDecoderSurface()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (session.width <= 0 || session.height <= 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})",
|
||||
)
|
||||
return
|
||||
}
|
||||
createOrReplaceDecoder(session)
|
||||
controller.ensureDecoder(session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +103,7 @@ object NativeCoreFacade {
|
||||
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
|
||||
)
|
||||
activeSurfaceId = null
|
||||
renderer.detachDisplaySurface(surface, releaseSurface = false)
|
||||
if (releaseDecoder) {
|
||||
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
}
|
||||
controller.detachDisplaySurface(surface, releaseDecoder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +115,10 @@ object NativeCoreFacade {
|
||||
) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
recordingSurfaceAttached = true
|
||||
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
|
||||
val session = currentSessionInfo
|
||||
if (session != null && decoder == null) {
|
||||
createOrReplaceDecoder(session)
|
||||
controller.attachRecordSurface(surface, width, height, onFrameRendered)
|
||||
val session = controller.getCurrentSessionInfo()
|
||||
if (session != null && !controller.isDecoderUsable()) {
|
||||
controller.ensureDecoder(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,18 +129,17 @@ object NativeCoreFacade {
|
||||
) {
|
||||
sessionLifecycleMutex.withLock {
|
||||
recordingSurfaceAttached = false
|
||||
renderer.detachRecordSurface(surface, releaseSurface)
|
||||
controller.detachRecordSurface(surface, releaseSurface)
|
||||
if (activeSurfaceId == null) {
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
controller.releaseAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
|
||||
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener)
|
||||
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener)
|
||||
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener)
|
||||
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = controller.addVideoSizeListener(listener)
|
||||
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = controller.removeVideoSizeListener(listener)
|
||||
fun addVideoFpsListener(listener: (Float) -> Unit) = controller.addVideoFpsListener(listener)
|
||||
fun removeVideoFpsListener(listener: (Float) -> Unit) = controller.removeVideoFpsListener(listener)
|
||||
|
||||
/**
|
||||
* Called by Scrcpy.kt when a session starts.
|
||||
@@ -177,41 +151,20 @@ object NativeCoreFacade {
|
||||
scrcpy: Scrcpy,
|
||||
) = sessionLifecycleMutex.withLock {
|
||||
scrcpyRef = scrcpy
|
||||
currentSessionInfo = session
|
||||
releaseAllDecoders()
|
||||
synchronized(bootstrapLock) {
|
||||
bootstrapPackets.clear()
|
||||
latestConfigPacket = null
|
||||
}
|
||||
controller.releaseAll()
|
||||
controller.resetBootstrap()
|
||||
if (activeSurfaceId != null || recordingSurfaceAttached) {
|
||||
// v4.0: width/height come from first video session packet, not from initial metadata
|
||||
if (session.width > 0 && session.height > 0) {
|
||||
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
|
||||
createOrReplaceDecoder(session)
|
||||
controller.ensureDecoder(session)
|
||||
} else {
|
||||
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
|
||||
}
|
||||
}
|
||||
packetCount = 0
|
||||
sessionMgr.attachVideoConsumer { packet ->
|
||||
cacheBootstrapPacket(packet)
|
||||
packetCount += 1
|
||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}",
|
||||
)
|
||||
}
|
||||
|
||||
val dec = decoder ?: return@attachVideoConsumer
|
||||
runCatching {
|
||||
dec.feedAnnexB(
|
||||
packet.data,
|
||||
packet.ptsUs,
|
||||
packet.isKeyFrame,
|
||||
packet.isConfig,
|
||||
)
|
||||
}
|
||||
cacheAndFeed(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,23 +178,7 @@ object NativeCoreFacade {
|
||||
val scrcpy = scrcpyRef ?: return@withLock
|
||||
val info = scrcpy.currentSessionState.value ?: return@withLock
|
||||
if (info.width <= 0 || info.height <= 0) return@withLock
|
||||
|
||||
if (decoder == null) {
|
||||
// Initial creation (v4.0: deferred until first session packet)
|
||||
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}")
|
||||
currentSessionInfo = info
|
||||
createOrReplaceDecoder(info)
|
||||
} else if (currentSessionInfo != null &&
|
||||
(info.width != currentSessionInfo!!.width || info.height != currentSessionInfo!!.height)
|
||||
) {
|
||||
// Flex display: rebuild decoder on size change
|
||||
Log.i(
|
||||
TAG,
|
||||
"onVideoSizeChanged(): rebuild decoder ${currentSessionInfo!!.width}x${currentSessionInfo!!.height} → ${info.width}x${info.height}",
|
||||
)
|
||||
currentSessionInfo = info
|
||||
createOrReplaceDecoder(info)
|
||||
}
|
||||
controller.rebuildDecoderForSize(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,150 +188,24 @@ object NativeCoreFacade {
|
||||
* Cleans up decoders and resets state.
|
||||
*/
|
||||
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
|
||||
releaseAllDecoders()
|
||||
synchronized(bootstrapLock) {
|
||||
bootstrapPackets.clear()
|
||||
latestConfigPacket = null
|
||||
}
|
||||
controller.releaseAll()
|
||||
scrcpyRef = null
|
||||
currentSessionInfo = null
|
||||
recordingSurfaceAttached = false
|
||||
}
|
||||
|
||||
private fun cacheAndFeed(packet: Scrcpy.Session.VideoPacket) {
|
||||
packetCount += 1
|
||||
if (packetCount == 1L || packetCount % 120L == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} usable=${controller.isDecoderUsable()}",
|
||||
)
|
||||
}
|
||||
val usable = controller.feed(packet)
|
||||
if (!usable) {
|
||||
Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "NativeCoreFacade"
|
||||
private const val MAX_BOOTSTRAP_PACKETS = 90
|
||||
|
||||
private data class CachedPacket(
|
||||
val data: ByteArray,
|
||||
val ptsUs: Long,
|
||||
val isConfig: Boolean,
|
||||
val isKeyFrame: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as CachedPacket
|
||||
|
||||
if (ptsUs != other.ptsUs) return false
|
||||
if (isConfig != other.isConfig) return false
|
||||
if (isKeyFrame != other.isKeyFrame) return false
|
||||
if (!data.contentEquals(other.data)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace the active decoder bound to [surface] for [session].
|
||||
*
|
||||
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder].
|
||||
* - The decoder's `onOutputSizeChanged` callback publishes size changes to
|
||||
* registered listeners on the main thread.
|
||||
* - Newly created decoders are fed with any cached bootstrap packets to allow
|
||||
* faster playback startup.
|
||||
*/
|
||||
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) {
|
||||
val surface = renderer.getDecoderSurface()
|
||||
decoder?.release()
|
||||
decoder = null
|
||||
Log.i(
|
||||
TAG,
|
||||
"createOrReplaceDecoder(): " +
|
||||
"codec=${session.codec?.string ?: "null"}, " +
|
||||
"size=${session.width}x${session.height}, " +
|
||||
"persistent=true",
|
||||
)
|
||||
val newDecoder = AnnexBDecoder(
|
||||
width = session.width,
|
||||
height = session.height,
|
||||
outputSurface = surface,
|
||||
mimeType = when (session.codec) {
|
||||
Codec.H264 -> "video/avc"
|
||||
Codec.H265 -> "video/hevc"
|
||||
Codec.AV1 -> "video/av01"
|
||||
else -> "video/avc"
|
||||
},
|
||||
onOutputSizeChanged = { width, height ->
|
||||
val current = currentSessionInfo
|
||||
if (current == null || (current.width == width && current.height == height)) {
|
||||
return@AnnexBDecoder
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
|
||||
)
|
||||
currentSessionInfo = current.copy(width = width, height = height)
|
||||
mainHandler.post {
|
||||
videoSizeListeners.forEach { listener ->
|
||||
runCatching { listener(width, height) }
|
||||
}
|
||||
}
|
||||
},
|
||||
onFpsUpdated = { fps ->
|
||||
mainHandler.post {
|
||||
videoFpsListeners.forEach { listener ->
|
||||
runCatching { listener(fps) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
decoder = newDecoder
|
||||
replayBootstrapPackets(newDecoder)
|
||||
}
|
||||
|
||||
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
|
||||
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
|
||||
if (snapshot.isEmpty()) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
|
||||
snapshot.forEach { packet ->
|
||||
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 fun releaseAllDecoders() {
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,21 @@ class AnnexBDecoder(
|
||||
@Volatile
|
||||
private var released = false
|
||||
|
||||
/**
|
||||
* Set when the codec reports an unrecoverable error (either during configure/start
|
||||
* or at runtime via [MediaCodec.CodecException] / [IllegalStateException]).
|
||||
*
|
||||
* Once non-null, [isUsable] returns false and subsequent [feedAnnexB] calls are no-ops.
|
||||
* The controller ([VideoDecoderController]) checks this to trigger session restart.
|
||||
*/
|
||||
@Volatile
|
||||
internal var codecError: Throwable? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
if (!outputSurface.isValid) {
|
||||
throw IllegalStateException("Cannot initialize decoder: output surface is not valid")
|
||||
throw DecoderException(mimeType, width, height,
|
||||
IllegalStateException("Output surface is not valid"))
|
||||
}
|
||||
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||
if (sps != null) {
|
||||
@@ -52,8 +64,14 @@ class AnnexBDecoder(
|
||||
if (pps != null) {
|
||||
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
|
||||
}
|
||||
codec.configure(format, outputSurface, null, 0)
|
||||
codec.start()
|
||||
try {
|
||||
codec.configure(format, outputSurface, null, 0)
|
||||
codec.start()
|
||||
} catch (e: Exception) {
|
||||
runCatching { codec.release() }
|
||||
codecError = e
|
||||
throw DecoderException(mimeType, width, height, e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +86,7 @@ class AnnexBDecoder(
|
||||
*/
|
||||
@Synchronized
|
||||
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
|
||||
if (released) {
|
||||
if (released || codecError != null) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
@@ -107,9 +125,13 @@ class AnnexBDecoder(
|
||||
}
|
||||
drainOutput()
|
||||
}.onFailure {
|
||||
Log.w(
|
||||
// MediaCodec.CodecException and IllegalStateException indicate the codec is in an
|
||||
// unrecoverable error state (e.g. MTK OMX fatal error after surface conflicts).
|
||||
// Record the error so isUsable() returns false and the controller can react.
|
||||
codecError = it
|
||||
Log.e(
|
||||
TAG,
|
||||
"feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
|
||||
"feed failed (codec marked unusable): mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
|
||||
it,
|
||||
)
|
||||
}
|
||||
@@ -145,6 +167,12 @@ class AnnexBDecoder(
|
||||
runCatching { codec.release() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the decoder is still alive and the codec has not reported
|
||||
* an unrecoverable error.
|
||||
*/
|
||||
fun isUsable(): Boolean = !released && codecError == null
|
||||
|
||||
private fun drainOutput() {
|
||||
while (true) {
|
||||
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -62,6 +62,75 @@ class PersistentVideoRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically tear down and recreate the decoder-side surface (SurfaceTexture + OES texture
|
||||
* + Surface), returning a fresh [Surface] for a new MediaCodec to render into.
|
||||
*
|
||||
* This is needed because some OMX decoders (notably MTK) fail when a new codec is configured
|
||||
* against a surface that still has pending buffers from a previously released codec.
|
||||
* By recreating the underlying SurfaceTexture + texture, the new codec gets a clean buffer
|
||||
* queue with no leftover state.
|
||||
*
|
||||
* - Must be called after the old decoder has been released, so no producer is writing to
|
||||
* the old surface at the same time.
|
||||
* - Runs on the renderer HandlerThread (via [handler]) so it is serialized with [drawFrame].
|
||||
* - EGL context, display/record EGL surfaces, and the shader program are preserved; only
|
||||
* `oesTextureId` / `decoderSurfaceTexture` / `decoderSurface` are recreated.
|
||||
*/
|
||||
fun recreateDecoderSurface(): Surface {
|
||||
ensureInitialized()
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
var result: Surface? = null
|
||||
handler.post {
|
||||
try {
|
||||
if (released) {
|
||||
error("renderer already released")
|
||||
}
|
||||
recreateDecoderSurfaceLocked()
|
||||
result = decoderSurface
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
latch.await()
|
||||
return result ?: error("failed to recreate decoder surface")
|
||||
}
|
||||
|
||||
private fun recreateDecoderSurfaceLocked() {
|
||||
Log.i(tag, "recreateDecoderSurfaceLocked(): rebuilding decoder surface")
|
||||
|
||||
// Tear down old decoder-side resources. drawFrame() won't run concurrently
|
||||
// because we're on the same HandlerThread.
|
||||
runCatching { decoderSurface?.release() }
|
||||
decoderSurface = null
|
||||
runCatching { decoderSurfaceTexture?.release() }
|
||||
decoderSurfaceTexture = null
|
||||
if (oesTextureId != 0) {
|
||||
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
|
||||
oesTextureId = 0
|
||||
}
|
||||
|
||||
// Recreate from scratch (mirrors initializeLocked lines 243-259)
|
||||
oesTextureId = createExternalTexture()
|
||||
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
|
||||
setOnFrameAvailableListener(
|
||||
{
|
||||
val n = frameAvailableCount.incrementAndGet()
|
||||
if (n == 1L || n % 120L == 0L) {
|
||||
Log.i(
|
||||
tag,
|
||||
"onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}",
|
||||
)
|
||||
}
|
||||
drawFrame()
|
||||
},
|
||||
handler,
|
||||
)
|
||||
}
|
||||
decoderSurface = Surface(decoderSurfaceTexture)
|
||||
Log.i(tag, "recreateDecoderSurfaceLocked(): decoder surface rebuilt, texture=$oesTextureId")
|
||||
}
|
||||
|
||||
fun attachDisplaySurface(surface: Surface) {
|
||||
ensureInitialized()
|
||||
val newId = System.identityHashCode(surface)
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
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 [AnnexBDecoder] 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 (AnnexBDecoder 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: AnnexBDecoder? = 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 [AnnexBDecoder] 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 = when (session.codec) {
|
||||
Codec.H264 -> "video/avc"
|
||||
Codec.H265 -> "video/hevc"
|
||||
Codec.AV1 -> "video/av01"
|
||||
else -> "video/avc"
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"createOrReplaceDecoder(): codec=${session.codec?.string ?: "null"}, " +
|
||||
"size=${session.width}x${session.height}, " +
|
||||
"recreateSurface=$recreateSurface, " +
|
||||
"persistent=true",
|
||||
)
|
||||
val newDecoder = try {
|
||||
AnnexBDecoder(
|
||||
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@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) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} 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: AnnexBDecoder) {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user