refactor: break down NativeCoreFacade

- 捕获解码器错误
- 旋转屏幕重建不复用旧 buffer
This commit is contained in:
Miuzarte
2026-07-12 18:40:54 +08:00
parent d459fae571
commit 90a188f355
8 changed files with 604 additions and 247 deletions

View File

@@ -60,7 +60,7 @@ android {
minSdk = 26
targetSdk = 37
versionCode = 35
versionName = "0.4.4"
versionName = "0.4.5_pre1"
externalNativeBuild {
cmake {

View File

@@ -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
}
}

View File

@@ -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)

View File

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

View File

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

View File

@@ -0,0 +1,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
}
}