From 5d56e538af92d9493bbc18db63afe80c86a52f14 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 6 Aug 2026 16:26:34 -0500 Subject: [PATCH 1/2] Play the first item of a playlist on a fresh panel, instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newly paired panel always learns its playlist BEFORE the media arrives, so start() finds nothing playable and the 3-second content re-check is what really begins playback. updatePlaylist() has already seeded currentIndex = 0 for a playlist that has not started, but the re-check advanced PAST that index — so the first pass ran 1,2,3,0 and item 1 only appeared after the list wrapped. On the emulator, a fresh pair with a 4-item playlist reproduced it every time: Starting playback Playing: red.png (index 1) <- clip32.mp4 (index 0) never got its turn Playing: clip7.mp4 (index 2) Playing: blue.png (index 3) Playing: clip32.mp4 (index 0) <- 54s late, on the second pass On a two-item playlist that is indistinguishable from "only one of the two ever plays", which is how it was reported. The distinction the re-check was missing is hasContentOnScreen. With content up, currentIndex is a real position that has had its turn and the scan must move past it. With nothing up, currentIndex is only where playback INTENDED to start, so skipping it drops that item. PlaylistSelection.recheckIndex now makes that choice explicitly, and playableFromIndex treats a negative index as "no position yet" rather than wrapping onto the last item. Verified on the emulator against the same cold start: the first pass is now 0,1,2,3,4 in order. Playback resume (#234) is untouched — it never reaches the re-check when its target is cached, confirmed by an Activity relaunch resuming mid-playlist as before. Tests: 6 new cases in PlaylistSelectionTest covering both sides of the rule, the still-downloading item, the no-position-yet start, and the empty case. Android 151/151, server 1298/1298. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- .../player/player/PlaylistController.kt | 5 ++- .../player/player/PlaylistSelection.kt | 33 ++++++++++++++ .../player/player/PlaylistSelectionTest.kt | 45 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) 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/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))) + } } From 9a630087ffcb58403e7eef7e6c575bf657c71999 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 6 Aug 2026 16:40:48 -0500 Subject: [PATCH 2/2] 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)