Re-arm self-advance when follower mode is turned off

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.
This commit is contained in:
ScreenTinker 2026-07-28 22:23:02 -05:00
parent bcb1b5c7a3
commit 8cf395f63b
2 changed files with 114 additions and 2 deletions

View file

@ -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)
}
}

View file

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