From 9a630087ffcb58403e7eef7e6c575bf657c71999 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 6 Aug 2026 16:40:48 -0500 Subject: [PATCH] Show the video in a rotated wall panel's screenshot, not a black rectangle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #236 gave each video-wall panel a mounting rotation, which for the first time puts a real rotation on an ancestor of the ExoPlayer TextureView. The screenshot compositor could not express that: it pasted the video frame with an axis-aligned Rect built from getLocationInWindow(), so on a rotated panel the frame landed outside the capture bitmap entirely. What reached the dashboard was the plain black that view.draw() leaves wherever a TextureView is — a panel that looks dead while it is playing perfectly, which is the worst thing a diagnostic can say. The frame is now placed through the same transform chain the hierarchy was drawn with, accumulated up the parent chain the way the framework does when it draws a child, so any ancestor rotation/translation is honoured. The bitmap is also scaled from the surface's own dimensions rather than assumed to match the view. Measured on the emulator, a wall panel playing video, remote screenshot vs the adb framebuffer at the same moment (standard deviation — 0 means a flat frame): before after rotation 0 sd 0.439 / truth 0.430 sd 0.443 / truth 0.435 (unchanged) rotation 90 sd 0 / truth 0.461 sd 0.448 / truth 0.448 rotation 90 sd 0 / truth 0.467 sd 0.460 / truth 0.457 rotation 90 sd 0 / truth 0.408 sd 0.463 / truth 0.466 Every rotated capture was #010101 with zero variance before; each now tracks the real framebuffer. Rotation 0 is unchanged, and so is the ordinary fullscreen (non-wall) path, re-measured across images and video. No unit test: this is android.graphics.Matrix semantics against a live view hierarchy, which the JVM test source set cannot exercise — the evidence is the before/after measurement above. Android 151/151, server 1298/1298. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- .../player/remote/ScreenshotCapture.kt | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/remote/ScreenshotCapture.kt b/android/app/src/main/java/com/remotedisplay/player/remote/ScreenshotCapture.kt index 9586d06..9e25a2d 100644 --- a/android/app/src/main/java/com/remotedisplay/player/remote/ScreenshotCapture.kt +++ b/android/app/src/main/java/com/remotedisplay/player/remote/ScreenshotCapture.kt @@ -66,16 +66,25 @@ class ScreenshotCapture { if (tv.isAvailable && tv.visibility == View.VISIBLE) { val tvBitmap = tv.bitmap if (tvBitmap != null) { - val loc = IntArray(2) - tv.getLocationInWindow(loc) - val rootLoc = IntArray(2) - view.getLocationInWindow(rootLoc) - val x = (loc[0] - rootLoc[0]).toFloat() - val y = (loc[1] - rootLoc[1]).toFloat() - val destRect = Rect(x.toInt(), y.toInt(), x.toInt() + tv.width, y.toInt() + tv.height) - canvas.drawBitmap(tvBitmap, null, destRect, null) + // Place the frame through the SAME transform chain the hierarchy was drawn + // with, rather than an axis-aligned rect at getLocationInWindow(). + // + // #236 gave a video-wall panel a mounting rotation, which puts a real + // rotation on an ancestor of this TextureView. An axis-aligned rect cannot + // express that, so the frame was pasted un-rotated at a position that fell + // outside the capture bitmap entirely — and what the dashboard received was + // the plain black that view.draw() leaves wherever a TextureView is, i.e. a + // panel that looks dead while it is happily playing. Verified on the + // emulator: at rotation 90 every remote screenshot of a video came back + // #010101 with zero variance, while rotation 0 was correct. + val m = matrixTo(tv, view) + // The surface bitmap is not required to match the view's size. + if (tvBitmap.width > 0 && tvBitmap.height > 0) { + m.preScale(tv.width.toFloat() / tvBitmap.width, tv.height.toFloat() / tvBitmap.height) + } + canvas.drawBitmap(tvBitmap, m, null) tvBitmap.recycle() - Log.d("ScreenshotCapture", "Composited TextureView at ($x,$y) size=${tv.width}x${tv.height}") + Log.d("ScreenshotCapture", "Composited TextureView ${tv.width}x${tv.height} via $m") } } } @@ -114,6 +123,28 @@ class ScreenshotCapture { } } + /** + * The matrix mapping [view]'s own coordinates into [ancestor]'s, by walking up the parent chain + * and concatenating each step the way the framework does when it draws a child: the child's own + * matrix (rotation/scale/translation about its pivot) followed by its layout offset. + * + * Stops at [ancestor], or at the top of the View chain if it is never reached — a partial chain + * still places the frame better than ignoring the transform completely. + */ + private fun matrixTo(view: View, ancestor: View): android.graphics.Matrix { + val out = android.graphics.Matrix() + var v: View = view + while (true) { + val local = android.graphics.Matrix(v.matrix) // translationX/Y + rotation about pivot + local.postTranslate(v.left.toFloat(), v.top.toFloat()) + out.postConcat(local) // out = local * out (child-first) + val parent = v.parent + if (parent !is View || parent === ancestor) break + v = parent + } + return out + } + private fun findAllTextureViews(view: View, result: MutableList) { if (view is TextureView) { result.add(view)