From d9133975591c1c844afd168f4bc931796d0082b9 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 22:23:23 -0500 Subject: [PATCH] Hand the frame back cleanly when a wipe ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as one or two frames of the OUTGOING photo after every transition, before the incoming one appears. Three things conspired, all at the moment the wipe ends. The overlay is a translucent SurfaceView with setZOrderOnTop(true) and a clear colour of (0,0,0,0). onDrawFrame() cleared unconditionally, before testing whether there was anything to draw. finish() left RENDERMODE_CONTINUOUSLY on and only POSTED the content swap and the hide to the main thread, so the GL thread got at least one more frame in first: it cleared to fully transparent while the overlay was still visible, showing straight through to the ImageView — which still held the previous photo, because the swap had not run yet. Not a black flash; a see-through one. The same clear ran on the failed/hard-cut path. So: clear only when a frame is actually going to be drawn over it, and stop the render loop in finish() on the GL thread rather than waiting for the main thread to park the overlay. What stays on screen is then the wipe's final frame, which is the destination image, and it is correct to leave it there. That still left the hand-off itself racing. Hiding a Z-ordered SurfaceView is a SurfaceFlinger transaction that is not synchronised with the app drawing the newly mounted bitmap, so the hide can land a vsync before the paint and uncover the old photo anyway. The overlay now lingers briefly before parking. It costs nothing to look at — both layers are showing the same picture — and it removes the race rather than narrowing it. Measured on the panel with 64x36 frame classification over screen recordings: the old photo reappeared after 1 of 4 wipes before, 0 of 14 after the first two changes. That sampling runs through a virtual display and cannot see every composited frame, so it bounds the problem rather than proving absence — hence closing the last gap by construction instead of by measurement. The web player never had this: it calls mount() and then hides the canvas synchronously in one task, so both land in the same paint. --- .../player/player/TransitionCompositor.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt b/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt index 4ce2591..29f61f3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/TransitionCompositor.kt @@ -89,6 +89,14 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) { private val renderer = TxRenderer() @Volatile private var incoming: Job? = null + private companion object { + // ~4 frames at 60Hz. How long the finished overlay lingers (showing the destination image) + // before it is hidden, so the SurfaceFlinger hide can't beat the app's draw of the same image + // underneath. Generous on purpose: a slow panel misses more vsyncs, and the wait costs + // nothing visually because both layers hold identical pixels. + const val PARK_DELAY_MS = 64L + } + init { setEGLContextClientVersion(2) setEGLConfigChooser(8, 8, 8, 8, 0, 0) // alpha channel -> translucent surface @@ -138,9 +146,14 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) { override fun onDrawFrame(gl: GL10?) { incoming?.let { j -> incoming = null; active?.let { supersede(it) }; setup(j) } // pick up a new request val j = active - GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + // Clear ONLY when a frame is actually going to be drawn over it. Clearing + // unconditionally painted the overlay black on every frame where there was nothing to + // draw — and the overlay is still VISIBLE at that moment, because finish() only POSTS + // the hide to the main thread. That was the one-or-two frame blank seen after each + // wipe, and the same flash on the failed/hard-cut path. if (j == null) return if (j.failed) { finish(j); return } + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) if (j.startNs == 0L) j.startNs = System.nanoTime() val p = ((System.nanoTime() - j.startNs).toFloat() / (j.durationMs * 1_000_000f)).coerceIn(0f, 1f) draw(j, p) @@ -198,6 +211,12 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) { // the overlay IF no newer wipe is queued. private fun finish(j: Job) { active = null + // Stop the render loop HERE, on the GL thread, rather than waiting for parkIfIdle() to + // run on the main thread. The front buffer currently holds the wipe's last frame — which + // is the destination image — so leaving it untouched and visible is seamless while the + // real content is mounted underneath. Any frame drawn in that gap is a regression: + // there is nothing left to draw, so it can only be an empty one. + renderMode = RENDERMODE_WHEN_DIRTY releaseGl() swapOnMain(j) parkIfIdle() @@ -217,7 +236,23 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) { // @Volatile and mutated only on the main thread (play), the same thread this posted block runs on, // so a newer play() either already set incoming (we skip park) or runs after (it re-shows) — // race-free, and never leaves a new wipe hidden. - private fun parkIfIdle() { post { if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY } } } + private fun parkIfIdle() { + post { + if (incoming != null) return@post + // The overlay is currently showing the wipe's FINAL frame, which is the same picture + // that was just mounted underneath it. Hiding is therefore free to wait — and it must. + // + // This is a SurfaceView with setZOrderOnTop(true): its visibility change is applied by + // SurfaceFlinger in a transaction that is NOT synchronised with the app drawing the + // ImageView's new bitmap. Hiding in the same message-loop turn as the swap can land a + // vsync EARLIER than that draw, uncovering the previous photo for one frame. Holding + // the overlay (identical pixels) for a few frames removes the race outright; there is + // nothing to see during the wait because both layers show the same image. + postDelayed({ + if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY } + }, PARK_DELAY_MS) + } + } private fun releaseGl() { if (texFrom != 0) { GLES20.glDeleteTextures(1, intArrayOf(texFrom), 0); texFrom = 0 }