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" } + } + } +}