From 65f7babb2a46eac2ff8b8c3771364dbb01f63e92 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 20 Oct 2026 00:38:05 +0200 Subject: [PATCH] Register Android video presentation ACK before the frame draw --- .../com/utun/chat/data/CallVideoPlayer.kt | 6 ++- .../utun/chat/ui/screens/CallVideoPanel.kt | 45 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt index 64199848..d2d65aa3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoPlayer.kt @@ -48,6 +48,7 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin var lastOutput = -1L; var lastProgress = System.nanoTime() var reportedGeneration = 0L; var rejectedGeneration = 0L var reportAt = System.nanoTime(); var pulled = 0; var queued = 0; var decoded = 0; var rendered = 0; var late = 0 + var acknowledged = 0; var ackMaxUs = 0L val clock = LongArray(8); val info = MediaCodec.BufferInfo() fun close() { try { decoder?.stop() } catch (e: Exception) { LogManager.addLog("WARN", "call", "video decoder stop: ${e.message}") } @@ -71,6 +72,7 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin while (true) { val consumed = presentations.poll() ?: break if (consumed.surface === surface && consumed.pts == awaitingPts) { + acknowledged++; ackMaxUs = maxOf(ackMaxUs, (consumed.at * 1000 - lastRendered) / 1000) CallVideoNative.playoutPresented(playout, consumed.pts, consumed.at) awaitingPts = -1; awaiting = null; worked = true } @@ -172,8 +174,10 @@ internal class CallVideoPlayer(private val id: Long, private val failure: (Strin LogManager.addLog("DEBUG", "call", "video playback call=$id intervalMs=${(now - reportAt) / 1_000_000} " + "pulled=$pulled queued=$queued decoded=$decoded rendered=$rendered skipped=$late inflight=${inputs.size} " + "pending=${pending != null} ready=${pictures.size} totalLocal=$localQueued awaitingSurfacePts=$awaitingPts " + - "surfaceWaitMs=${if (awaitingPts >= 0) (now - lastRendered) / 1_000_000 else 0} audioValid=$valid audioPts=${clock[4]}") + "surfaceWaitMs=${if (awaitingPts >= 0) (now - lastRendered) / 1_000_000 else 0} " + + "acknowledged=$acknowledged surfaceAckMaxUs=$ackMaxUs audioValid=$valid audioPts=${clock[4]}") reportAt = now; pulled = 0; queued = 0; decoded = 0; rendered = 0; late = 0 + acknowledged = 0; ackMaxUs = 0 } } catch (e: Exception) { LogManager.addLog("ERROR", "call", "video receive call=$id: ${e.message}") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt index 4af748b9..e611d489 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallVideoPanel.kt @@ -6,6 +6,7 @@ import android.graphics.SurfaceTexture import android.os.Build import android.view.Surface import android.view.TextureView +import android.view.ViewTreeObserver import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState @@ -211,11 +212,17 @@ private fun CallVideoPlayback(video: CallVideoEngine, onTap: () -> Unit, modifie } } -private class CallVideoTexture(context: Context, private val video: CallVideoEngine) : TextureView(context) { +private class CallVideoTexture(context: Context, private val video: CallVideoEngine) : TextureView(context), ViewTreeObserver.OnPreDrawListener { private var attached: Surface? = null private var closed = false private var format: CallVideoFormat? = null private var large = false + private var observer: ViewTreeObserver? = null + private var reportAt = 0L + private var updates = 0 + private var commits = 0 + private var updateAt = 0L + private var commitMaxUs = 0L fun update(next: CallVideoFormat?, expanded: Boolean) { if (format == next && large == expanded) return format = next; large = expanded; fit() @@ -232,16 +239,42 @@ private class CallVideoTexture(context: Context, private val video: CallVideoEng override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { fit() } override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { closeOutput(); return false } override fun onSurfaceTextureUpdated(texture: SurfaceTexture) { - val surface = attached ?: return - // TextureView callback предшествует dequeue RenderThread; timestamp читаем после commit. - if (Build.VERSION.SDK_INT >= 29) viewTreeObserver.registerFrameCommitCallback { consumed(texture, surface) } - else postOnAnimation { consumed(texture, surface) } + updateAt = System.nanoTime(); updates++ } } } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + observer = viewTreeObserver.also { it.addOnPreDrawListener(this) } + LogManager.addLog("DEBUG", "call", "video presentation attached preDrawCommit=true hardware=$isHardwareAccelerated") + } + override fun onDetachedFromWindow() { + observer?.takeIf { it.isAlive }?.removeOnPreDrawListener(this); observer = null + super.onDetachedFromWindow() + } + override fun onPreDraw(): Boolean { + val texture = surfaceTexture; val surface = attached + if (!closed && texture != null && surface != null) { + // ViewRoot собирает commit callbacks ДО TextureView.draw/onSurfaceTextureUpdated. + // Регистрируем до draw, иначе ACK ждёт следующего постороннего обновления окна. + if (Build.VERSION.SDK_INT >= 29 && isHardwareAccelerated) + viewTreeObserver.registerFrameCommitCallback { consumed(texture, surface) } + else postOnAnimation { consumed(texture, surface) } + } + return true + } private fun consumed(texture: SurfaceTexture, surface: Surface) { if (closed || attached !== surface) return - if (!video.presented(surface, texture.timestamp / 1000)) postOnAnimation { consumed(texture, surface) } + val now = System.nanoTime(); commits++ + if (updateAt != 0L) { commitMaxUs = maxOf(commitMaxUs, (now - updateAt) / 1000); updateAt = 0 } + // Если timestamp ещё старый, новое TextureView update само назначит draw/commit. + video.presented(surface, texture.timestamp / 1000) + if (reportAt == 0L) reportAt = now + if (now - reportAt >= 1_000_000_000) { + LogManager.addLog("DEBUG", "call", "video surface intervalMs=${(now - reportAt) / 1_000_000} " + + "updates=$updates commits=$commits updateToCommitMaxUs=$commitMaxUs pts=${texture.timestamp / 1000}") + reportAt = now; updates = 0; commits = 0; commitMaxUs = 0 + } } private fun fit() { val f = format ?: return