mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 15:23:16 -06:00
Merge branch 'fix/resume-self-advance-on-follower-exit'
This commit is contained in:
commit
0b6907704e
|
|
@ -84,8 +84,36 @@ class PlaylistController(
|
||||||
|
|
||||||
val isPlaying: Boolean get() = isRunning && currentIndex >= 0
|
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). */
|
/** Current playlist index (-1 if nothing is playing). */
|
||||||
fun getIndex(): Int = currentIndex
|
fun getIndex(): Int = currentIndex
|
||||||
|
|
@ -496,3 +524,26 @@ class PlaylistController(
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue