Now-playing pager without a feedback loop

SkillDev tools

A now-playing artwork pager that both follows the player and drives it, without the two writing to each other in a loop — the in-progress-scroll flag covers programmatic animation as well as drags, the seek is dispatched from the settled page only, and the page-difference decision is a pure function outside the UI runtime. Use when a swipe bounces back to the page it came from, when one swipe skips two tracks, when the pager stops following the player after a fast swipe, when swiping backwards restarts the current track instead of going back one, or when a far swipe under shuffle lands on the wrong song.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Now-playing pager without a feedback loop skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/nowplaying-pager-no-feedback-loop/SKILL.md and read by ahel’s review.

The pager is a two-way binding: it slides when the player advances, and it seeks when the user settles on another page. Each direction writes what the other watches — keep one quiet at a time.

Traps

The in-progress-scroll flag is true for programmatic animation too. It is the obvious way to mean "the user is dragging" and it is wrong: the pager sets it identically for a drag and for the animate-to-page call your own follow effect just made, so it blocks the follow while it runs. Use a flag you own:

// adapted
var isAnimatingFromPlayer by remember { mutableStateOf(false) }
var isUserDraggingActive by remember { mutableStateOf(false) }
LaunchedEffect(pagerState) {
    snapshotFlow { pagerState.isScrollInProgress to isAnimatingFromPlayer }
        .collect { (scrolling, animating) -> isUserDraggingActive = scrolling && !animating }
}

Bracket every programmatic scroll with that flag in a try/finally. Not tidiness — the effect is cancelled whenever its keys change, which is what happens when the track advances again mid-animation, and a flag left true by that cancellation blocks every later write permanently.

// adapted
LaunchedEffect(currentIndex, queue.size) {
    val target = currentIndex
    if (!isUserDraggingActive && target in queue.indices && target != pagerState.currentPage) {
        isAnimatingFromPlayer = true
        try { pagerState.animateScrollToPage(target) } finally { isAnimatingFromPlayer = false }
    }
}

Dispatch from the settled page, never the current or target page. The current page flips as soon as a drag crosses the halfway point and can flip back inside one gesture; the target page changes with the fling. Either one sends a seek per crossing — "one swipe skipped two tracks":

// adapted
LaunchedEffect(pagerState, currentIndex, queue.size) {
    snapshotFlow { pagerState.settledPage }
        .distinctUntilChanged()
        .collect { settled ->
            if (isAnimatingFromPlayer) return@collect
            if (settled !in queue.indices) return@collect
            if (settled == currentIndex) return@collect
            dispatch(computeSeekAction(settled, currentIndex))
        }
}

The last guard is the loop-breaker: after the player moves and the follow effect slides the pager to match, the pager settles and emits — without it, that emission goes back at the player as a seek it has already done.

Keep the decision a pure function, outside the composition — four outcomes from one subtraction, in a file importing a domain model and nothing from the UI toolkit, so it is exercisable with no UI:

// as written
internal fun computeSeekAction(
    newPage: Int,
    currentOrderIndex: Int,
): ArtworkSeekAction =
    when (newPage - currentOrderIndex) {
        0 -> ArtworkSeekAction.NoOp
        1 -> ArtworkSeekAction.Next
        -1 -> ArtworkSeekAction.Previous
        else -> ArtworkSeekAction.Skip(newPage)
    }

The branch exists because adjacent and far moves are different commands: a ±1 move reuses the player's own next/previous, preserving whatever transition machinery the adapter runs between neighbouring items (see crossfade-dual-player); a longer jump goes through seek-by-index.

Use the skip-to-previous-item command for a backwards swipe. Plain previous implements the standard "restart the current track if the playhead is past a few seconds" rule — a literal position threshold in the desktop adapter, a configurable maximum-seek-to-previous-position on the mobile engine. Swipe backwards half a minute in and the player restarts that song, leaving the pager a page away; the follow effect slides it back and the swipe bounces. Every other caller of "previous" still wants the restart rule, so this is a second command, not a changed one.

The page index is in play order; the engine's seek-by-index is in timeline order. The pages are the queue the user can see, which under shuffle is a permutation of the engine's list — full treatment in queue-index-vs-shuffle-space. Route the far skip through the handler entry point that converts (if (shuffleModeEnabled) getUnshuffledIndex(index) else index), or a far swipe under shuffle plays a different song than the one under the finger. The ±1 branch is unaffected.

Derive the page from the queue using the id the queue actually stores. Engines are commonly handed a decorated media id — a prefix naming which rendition is loaded, or a cache key — and comparing that against the plain queue ids fails for exactly the decorated entries, so the pager sits on page 0 whenever one of them plays.

// as written
internal fun deriveOrderIndex(
    queue: List<Track>,
    nowPlayingVideoId: String?,
): Int {
    if (queue.isEmpty() || nowPlayingVideoId.isNullOrEmpty()) return 0
    return queue
        .indexOfLast { it.videoId == nowPlayingVideoId }
        .coerceAtLeast(0)
}

The coercion keeps the pager on a valid page while the queue has been replaced and the now-playing state has not caught up. Matching by id cannot tell two copies of one track apart, so read queue-index-vs-shuffle-space first — and for the same reason page keys must stay unique when the queue is not: build the key from the id and the index, or a repeat collides.

Two edge states need explicit handling. A queue that shrinks below the current page leaves the pager past the end — watch the size and scroll to the last index. And with repeat-one on, next/previous do not move, so a settled swipe is dragged straight back: disable scrolling (userScrollEnabled = !isRepeatOne && queue.isNotEmpty()) instead of letting the user fight it.

Verifying it

  1. grep -rn --include="*.kt" "isScrollInProgress\|animateScrollToPage\|settledPage\|scrollToPage(" . — the four sync points together. Every animated scroll must sit inside the owned flag's try/finally; an instant scrollToPage snaps, so it emits no intermediate pages — but its one settled destination still reads as a swipe, so bracket it too unless the guards already drop it.
  2. grep -rn "^import" $(grep -rl --include="*.kt" "fun computeSeekAction" .) — the decision file must import no UI-toolkit package at all; a domain model and nothing else is the expected listing.
  3. grep -rn --include="*.kt" "seekToPrevious()\|seekToPreviousMediaItem()" . — both must exist on the player interface and every adapter, and the backwards branch must reach the media-item one.
  4. By hand, in order: swipe forward one page (one advance, no bounce); let a track end on its own (the pager slides, no seek goes back); swipe backwards well past the restart threshold (previous track, not a restart); swipe several pages quickly (one jump, landing where you released); repeat with shuffle on, checking the audible track against the artwork; then turn repeat-one on.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
nowplaying-pager-no-feedback-loop
Source
github.com/maxrave-dev/kotlin-footguns