fix(android): stop zero-duration widget self-loop pegging the main thread (#198)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

A solo fullscreen widget (or image) with duration_sec=0 hit an unclamped
scheduleAdvance(item.durationSec * 1000L) in PlaylistController.playCurrentItem,
scheduling a 0ms auto-advance. For a single-item playlist next() re-selects the
same item, so it re-played every looper tick (~20x/sec) — black-screening the TV
and locking the UI (couldn't even reach home). Triggered when a schedule collapses
the playlist to a single always-on duration-0 widget.

Use slotMs() (the max(1, duration||10) contract shared with the web/Tizen players)
so a zero/negative duration floors to 10s. Also floor scheduleAdvance() itself to
MIN_ADVANCE_MS (500ms) as a backstop so no future path can busy-loop the main thread.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-17 00:01:19 -05:00 committed by GitHub
parent 059ee1744e
commit af286826dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -36,7 +36,13 @@ class PlaylistController(
// nothing has ever played (fresh device). Never used while content is on screen.
private val onWaitingForContent: (() -> Unit)? = null
) {
private companion object { const val CONTENT_RECHECK_MS = 3000L }
private companion object {
const val CONTENT_RECHECK_MS = 3000L
// Backstop against a busy-loop: an auto-advance is never scheduled faster than
// this, so a bad/zero duration on any path can't peg the main thread (see #widget
// zero-duration self-loop — a solo fullscreen widget with duration_sec=0).
const val MIN_ADVANCE_MS = 500L
}
private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1
@ -325,14 +331,19 @@ class PlaylistController(
// for the completion callback. Wall followers never auto-advance — the
// leader's wall:sync index drives every switch.
if (!wallFollower && (item.mimeType.startsWith("image/") || item.isWidget)) {
scheduleAdvance(item.durationSec * 1000L)
// slotMs() floors a zero/negative duration to 10s (the max(1, duration||10)
// contract shared with the web/Tizen players). A raw durationSec*1000 here let a
// solo fullscreen widget with duration_sec=0 schedule a 0ms advance -> self-loop.
scheduleAdvance(slotMs(item))
}
}
private fun scheduleAdvance(delayMs: Long) {
cancelAdvance()
// Backstop: never busy-loop, even if a future caller passes a tiny/zero delay.
val safeDelayMs = maxOf(delayMs, MIN_ADVANCE_MS)
advanceRunnable = Runnable { next() }
handler.postDelayed(advanceRunnable!!, delayMs)
handler.postDelayed(advanceRunnable!!, safeDelayMs)
}
private fun cancelAdvance() {