From 8cf395f63bc3cabba4cbc9650ef6cdf968d05df8 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 22:23:02 -0500 Subject: [PATCH] Re-arm self-advance when follower mode is turned off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While follower mode is on — a video wall follower, or a group-sync member — playCurrentItem() deliberately never calls scheduleAdvance(): the wall/group tick owns the index instead. Leaving that mode cleared the flag but re-armed nothing, so the item already on screen had no timer behind it and the playlist stopped dead. Unchecking "sync" on a group froze every member showing an image, until the app was restarted. A 30-frame sample of a real panel returned exactly one unique frame. Video hid the damage: onVideoComplete() -> next() still fires once repeatMode drops back to OFF, so a video playlist recovers on its own and only images and widgets strand. Both wall and group exit run through setWallFollower(), so the fix belongs there rather than in either controller. The entering edge was wrong in the same way, oppositely: a timer armed by the last playCurrentItem() stayed live across the transition into follower mode and would fire a next() that fights the tick for the index. It is now cancelled. Resume is measured from when the item actually started, so leaving sync 8s into a 10s image advances in ~2s rather than restarting the full slot; an already-elapsed slot yields 0 and the existing MIN_ADVANCE_MS backstop keeps that off a busy loop. FollowerExit is a pure seam so the arithmetic is testable without a Handler. Verified on the panel that reproduced it: "follower mode off — resuming self-advance in 9233ms", same pid, 40 frames / 7 unique / 9 advances where it previously froze. --- .../player/player/PlaylistController.kt | 55 ++++++++++++++++- .../player/player/FollowerExitTest.kt | 61 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 android/app/src/test/java/com/remotedisplay/player/player/FollowerExitTest.kt 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 60b5dfc..353439c 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 @@ -84,8 +84,36 @@ class PlaylistController( val isPlaying: Boolean get() = isRunning && currentIndex >= 0 - /** Video wall: true on followers (suppress auto-advance; leader drives the index). */ - fun setWallFollower(b: Boolean) { wallFollower = b } + /** + * Enter/leave follower mode (video wall follower, or group sync). While it is ON no advance + * timer is ever armed — playCurrentItem() skips scheduleAdvance() and the wall/group tick owns + * the index instead. + * + * That makes BOTH edges stateful, and both were being ignored: + * - leaving, the flag was cleared but nothing re-armed the timer for the item already on + * screen, so the playlist sat on one frame forever (until the app restarted). Unchecking + * "sync" on a group froze every member showing an image. + * - entering, a timer armed by the previous playCurrentItem() stayed live and would fire a + * next() that fights the tick for the index. + * + * Video is exempt on the way out: it advances from its completion callback, which onVideoComplete + * already gates on this same flag. + */ + fun setWallFollower(b: Boolean) { + val was = wallFollower + wallFollower = b + if (was == b) return + if (b) { cancelAdvance(); return } + val item = currentItem ?: return + val delay = FollowerExit.resumeDelayMs( + isRunning = isRunning, + isImageOrWidget = item.mimeType.startsWith("image/") || item.isWidget, + slotMs = slotMs(item), + elapsedMs = System.currentTimeMillis() - itemStartedAt + ) ?: return + Log.i("PlaylistController", "follower mode off — resuming self-advance in ${delay}ms") + scheduleAdvance(delay) + } /** Current playlist index (-1 if nothing is playing). */ fun getIndex(): Int = currentIndex @@ -496,3 +524,26 @@ class PlaylistController( return out } } + +/** + * Pure decision for re-arming self-advance when follower mode (video wall / group sync) is turned + * off, extracted so it's unit-testable without a Handler or Android runtime — same seam pattern as + * TierLogic / ManagedLogic. + * + * Returns the delay to schedule, or null when nothing should be armed. The remaining slot is + * measured from when the item actually started, so leaving sync 8s into a 10s image advances in + * ~2s rather than restarting the full duration. A slot that already elapsed yields 0 and the + * caller's MIN_ADVANCE_MS backstop keeps it off a busy loop. + */ +object FollowerExit { + fun resumeDelayMs( + isRunning: Boolean, + isImageOrWidget: Boolean, + slotMs: Long, + elapsedMs: Long + ): Long? { + if (!isRunning) return null // nothing is playing; playCurrentItem() will arm it + if (!isImageOrWidget) return null // video advances from its completion callback + return (slotMs - elapsedMs).coerceAtLeast(0L) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/player/FollowerExitTest.kt b/android/app/src/test/java/com/remotedisplay/player/player/FollowerExitTest.kt new file mode 100644 index 0000000..e6b6726 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/player/FollowerExitTest.kt @@ -0,0 +1,61 @@ +package com.remotedisplay.player.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Leaving follower mode (video wall follower, or group sync) has to re-arm self-advance. + * + * While follower mode is on, playCurrentItem() never calls scheduleAdvance() — the wall/group tick + * owns the index. So clearing the flag is not enough: the item on screen has NOTHING scheduled to + * move it. Unchecking "sync" on a group left every member showing an image frozen on one frame + * until the app was restarted, which is exactly what a 30-frame sample of a real panel showed — + * one unique frame, indefinitely. + */ +class FollowerExitTest { + + private val TEN_SEC = 10_000L + + @Test fun THE_BUG_an_image_mid_slot_gets_the_remaining_time() { + // 8s into a 10s image: advance in ~2s, don't restart the whole slot. + assertEquals(2_000L, FollowerExit.resumeDelayMs( + isRunning = true, isImageOrWidget = true, slotMs = TEN_SEC, elapsedMs = 8_000L)) + } + + @Test fun THE_BUG_an_image_whose_slot_already_elapsed_advances_immediately() { + // The freeze case: sync held this item well past its duration. 0 is honest; the caller's + // MIN_ADVANCE_MS backstop keeps it off a busy loop. + assertEquals(0L, FollowerExit.resumeDelayMs( + isRunning = true, isImageOrWidget = true, slotMs = TEN_SEC, elapsedMs = 45_000L)) + } + + @Test fun a_freshly_started_image_gets_its_full_slot() { + assertEquals(TEN_SEC, FollowerExit.resumeDelayMs( + isRunning = true, isImageOrWidget = true, slotMs = TEN_SEC, elapsedMs = 0L)) + } + + @Test fun a_widget_is_time_driven_like_an_image() { + assertEquals(3_000L, FollowerExit.resumeDelayMs( + isRunning = true, isImageOrWidget = true, slotMs = TEN_SEC, elapsedMs = 7_000L)) + } + + @Test fun video_is_left_alone_because_it_advances_on_completion() { + // onVideoComplete() -> next() already handles this, and arming a timer would cut the clip. + assertNull(FollowerExit.resumeDelayMs( + isRunning = true, isImageOrWidget = false, slotMs = TEN_SEC, elapsedMs = 8_000L)) + } + + @Test fun nothing_is_armed_when_the_playlist_is_not_running() { + // playCurrentItem() will arm it when playback actually starts. + assertNull(FollowerExit.resumeDelayMs( + isRunning = false, isImageOrWidget = true, slotMs = TEN_SEC, elapsedMs = 1_000L)) + } + + @Test fun the_delay_is_never_negative() { + for (elapsed in listOf(10_001L, 60_000L, Long.MAX_VALUE / 2)) { + val d = FollowerExit.resumeDelayMs(true, true, TEN_SEC, elapsed)!! + assert(d >= 0L) { "delay must never be negative, got $d for elapsed=$elapsed" } + } + } +}