Show the video in a rotated wall panel's screenshot, not a black rectangle

#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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-06 16:40:48 -05:00
parent 5d56e538af
commit 9a630087ff

View file

@ -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<TextureView>) {
if (view is TextureView) {
result.add(view)