Merge branch 'fix/resume-self-advance-on-follower-exit'

This commit is contained in:
ScreenTinker 2026-07-28 22:35:40 -05:00
commit 0b6907704e
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" }
}
}
}