Merge QA: a fresh panel no longer skips its first item; rotated wall screenshots

This commit is contained in:
ScreenTinker 2026-08-06 17:45:42 -05:00
commit 32189d1784
4 changed files with 122 additions and 10 deletions

View file

@ -544,7 +544,10 @@ class PlaylistController(
retryRunnable = Runnable {
if (isRunning && items.isNotEmpty()) {
if (firstActiveIndex() < 0) { showNothingScheduled(); return@Runnable }
val idx = PlaylistSelection.nextPlayableIndex(items.size, currentIndex) { playableNow(it) }
// Include currentIndex when nothing is on screen: there it is the intended START,
// not a position that has had its turn. Skipping it dropped item 1 on every cold
// start, because a fresh panel always gets the playlist before the media.
val idx = PlaylistSelection.recheckIndex(items.size, currentIndex, hasContentOnScreen) { playableNow(it) }
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else onContentNotReady()
}
}

View file

@ -31,6 +31,39 @@ object PlaylistSelection {
return -1
}
/**
* First playable index AT OR AFTER [from] (wrapping), or -1 if none. A negative [from] means
* "no position yet" and starts at 0 rather than wrapping onto the last item.
*/
fun playableFromIndex(size: Int, from: Int, isPlayable: (Int) -> Boolean): Int {
if (size <= 0) return -1
val start = if (from < 0) 0 else from % size
for (i in 0 until size) {
val idx = (start + i) % size
if (isPlayable(idx)) return idx
}
return -1
}
/**
* Which item a content re-check should play once something finally becomes ready.
*
* [hasContentOnScreen] is the entire distinction, and getting it wrong costs the operator the
* first item of their playlist. When content IS up, currentIndex is a real position that has
* already had its turn, so the scan must move PAST it. When nothing is up, currentIndex is only
* where playback INTENDED to begin updatePlaylist() seeds it to 0 for a playlist that has not
* started yet so it has never been shown, and advancing past it silently drops item 1 from the
* first pass through the playlist.
*
* That is the cold-start case on every fresh panel: the playlist arrives before its media has
* downloaded, start() finds nothing playable, and the re-check three seconds later is what
* actually begins playback. On a two-item playlist it looks exactly like "only one of the two
* ever plays" until the list wraps.
*/
fun recheckIndex(size: Int, from: Int, hasContentOnScreen: Boolean, isPlayable: (Int) -> Boolean): Int =
if (hasContentOnScreen) nextPlayableIndex(size, from, isPlayable)
else playableFromIndex(size, from, isPlayable)
enum class NonePlayable { KEEP_CURRENT, SHOW_WAITING }
/**

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)

View file

@ -57,4 +57,49 @@ class PlaylistSelectionTest {
@Test fun `a single downloaded item loops instead of blanking`() {
assertEquals(0, PlaylistSelection.nextPlayableIndex(1, 0, readyPredicate(0)))
}
// ===== the cold-start re-check: item 1 of the playlist must not be skipped =====
//
// Observed on the emulator: a freshly paired panel gets its playlist BEFORE the media has
// downloaded, so start() finds nothing playable and the 3-second content re-check is what
// actually begins playback. updatePlaylist() has already seeded currentIndex = 0, and the
// re-check used to advance PAST it — so a 4-item playlist played 1,2,3,0 on its first pass and
// a 2-item playlist looked like "only one of the two ever plays".
@Test fun `a cold-start re-check begins at the seeded index instead of skipping past it`() {
// currentIndex seeded to 0, nothing on screen yet, everything now downloaded.
assertEquals("item 0 has never played — it must not be skipped", 0,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2, 3)))
}
@Test fun `a re-check with content already on screen still advances past the current item`() {
// The other half of the rule: a real position has had its turn, so we must move on.
assertEquals(1,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = true, isPlayable = readyPredicate(0, 1, 2, 3)))
}
@Test fun `a cold-start re-check still skips an item whose content is not downloaded`() {
// Item 0 is still downloading; the panel starts on the first item it can actually show.
assertEquals(2,
PlaylistSelection.recheckIndex(4, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate(2, 3)))
}
@Test fun `a cold-start re-check with no position yet starts at the top, not the last item`() {
// currentIndex is -1 before updatePlaylist seeds it; wrapping onto the last item here would
// start a fresh panel at the END of its playlist.
assertEquals(0,
PlaylistSelection.recheckIndex(3, from = -1, hasContentOnScreen = false, isPlayable = readyPredicate(0, 1, 2)))
}
@Test fun `a cold-start re-check returns -1 while nothing is downloaded`() {
assertEquals(-1,
PlaylistSelection.recheckIndex(3, from = 0, hasContentOnScreen = false, isPlayable = readyPredicate()))
}
@Test fun `playableFromIndex is inclusive of its start and wraps`() {
assertEquals(1, PlaylistSelection.playableFromIndex(4, 1, readyPredicate(1, 3)))
assertEquals(3, PlaylistSelection.playableFromIndex(4, 2, readyPredicate(1, 3)))
assertEquals(1, PlaylistSelection.playableFromIndex(4, 3, readyPredicate(1))) // wraps
assertEquals(-1, PlaylistSelection.playableFromIndex(0, 0, readyPredicate(0)))
}
}