diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index 85f9885..fbd8c58 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -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() } } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt index 0936f0b..c22b1b8 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistSelection.kt @@ -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 } /** 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) diff --git a/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt b/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt index d4055bd..e3983ff 100644 --- a/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/player/PlaylistSelectionTest.kt @@ -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))) + } }