mcpbeat Sign in

Nowplaying Pager No Feedback Loop Agent Skill

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.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill nowplaying-pager-no-feedback-loop

The instruction itself

3 sections, as written by the author

Now-playing pager without a feedback loop

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

  • 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.

  • 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.

  • 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.

  • 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.

How to use it

Copy the folder

Take maxrave-dev/nowplaying-pager-no-feedback-loop from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.