mcpbeat Sign in

Queue Index Vs Shuffle Space Agent Skill

A player's "current index" is exposed to the UI but freezes on track change or highlights the wrong row once shuffle is on, because the engine's timeline order and the shuffled play order are two different index spaces. Use when the now-playing marker in a queue list is stuck, points one row off, or lights up every copy of a repeated track.

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 queue-index-vs-shuffle-space

The instruction itself

3 sections, as written by the author

Queue index vs shuffle space

A media engine holds items in timeline order — the order you added them. When shuffle is on it

plays them in a shuffled order, which is a permutation stored separately. Anything the engine

reports as "current index" is in timeline space. Anything a list on screen shows is in play order.

They are equal only while shuffle is off, which is why this bug ships.

The fix has two halves, both at the player layer, never in the list composable:

  • Publish the current index from the track-change callback, not only from queue mutations.
  • Expose both directions of the permutation and convert by position — never by matching ids.
interface MediaPlayerInterface {
    // play order -> timeline: "the user tapped row N of what they see"
    fun getUnshuffledIndex(shuffledIndex: Int): Int
    // timeline -> play order: "which row do I highlight"
    fun getShuffledIndex(timelineIndex: Int): Int
}

Each adapter already keeps the permutation to answer next/previous. Both directions are one lookup

into the two arrays it already maintains — see custom-shuffle-order for the pair.

Traps

The index state is written by every mutation except the one that matters. A backing

MutableStateFlow<Int> typically gets assigned inside moveMediaItem, removeMediaItem,

moveItemUp/Down and a public setter — and *not* inside the track-transition callback. The flow

then holds the index of the last edit forever, so the marker moves when the user drags a row and

never when a song ends. To verify, grep every write of the backing field and check the transition

callback against that list:

grep -n "_currentSongIndex" HandlerImpl.kt      # every write site
grep -n "onMediaItemTransition" HandlerImpl.kt  # is it among them?

A public setter with no callers is the tell that someone meant to wire this and stopped.

The index and the list it indexes live in different spaces. player.currentMediaItemIndex is

timeline space; a UI list that was re-derived from the engine timeline (see dual-source-queue-sync)

is play order. Handing one to the other is correct with shuffle off and silently wrong with it on —

which is exactly why it survives review. Verify by turning shuffle on with a queue longer than the

screen and checking whether the highlighted row is the audible one.

Resolving the playing row by matching ids breaks on duplicates. The usual patch is:

// wrong: every copy of a repeated track matches
queue.listTracks.indexOfLast { it.trackId == player.currentMediaItem?.mediaId }

A queue can legitimately hold the same track twice — the user queued it again, or a continuation

page re-served it. Both copies then satisfy the predicate, so indexOfFirst highlights the wrong

one and indexOfLast highlights the other wrong one. Ids identify *content*; positions identify

*queue entries*. The playing entry is a position, so only a position can answer it. Prefer a

position the player already reports (its own order index) over a fresh id search — but validate it

against the id before trusting it, and fall back to the id search when they disagree: during a queue

rebuild the list and the player timeline are briefly out of step, and an unvalidated position can

point at some other track entirely.

Only one direction of the map usually exists. Adapters implement play-order → timeline first,

because tapping a row needs it: player.seekTo(getUnshuffledIndex(tappedRow), 0). Nothing needs the

reverse until you draw the marker, so it is missing, and the id-matching hack above appears to fill

the gap. Verify before writing the hack: grep the player interface for an index-conversion function

and count the directions.

The id you compare may not be the id you stored. Engines are often handed a decorated media id —

a prefix marking which of several renditions of one track is loaded, or a cache key. A comparison

that works for plain audio then quietly fails for every decorated entry:

it.trackId == player.currentMediaItem?.mediaId?.removePrefix(VIDEO_MARKER)

That removePrefix is load-bearing. Any position-based fix removes the need for it entirely, which

is a good sign you picked the right fix.

A shuffle permutation is not stable across queue edits. Insert, remove and re-shuffle each

produce a *new* permutation, so an index cached in a view model outlives its meaning. Read it fresh

from the player on every use, or re-publish it from the callbacks that change the queue.

Fixing it in the list composable looks like it works. Deduplicating in the row's isPlaying

check — comparing an index the composable computed itself — makes the visible symptom go away in

the one screen you tested and leaves it in the queue sheet, the mini player and the artwork pager,

each of which computes its own. Any component that needs "which entry is playing" is a consumer of

one value the player layer should publish.

**Reconstructing an absolute index from a sliced sublist is a third index space, and it looks like

arithmetic rather than state.** A queue view that renders only the *upcoming* tracks slices the full

list first and then, at click time, adds the slice's own offset back to a local position to get an

index the player understands:

// adapted — names generalized; wrong: index is local to `upcoming`, offset is read again separately
val upcoming = queue.drop(offset)
itemsIndexed(upcoming) { index, track ->
    onClick = { actions.onSeekToQueueIndex(offset + index) }   // reconstructed, not carried
}

A sibling view of the exact same queue that renders the *whole* list has nothing to reconstruct —

itemsIndexed already hands it the absolute index — so it never shows this failure, which is the

first clue when one screen mishandles a tap and its twin does not. Attach the absolute index to each

element *before* slicing, so it survives the slice as data instead of being rebuilt from a value read

a second time:

// adapted — names generalized; the key is simplified (the real one concatenates index and track id)
val upcoming = queue.withIndex().drop(offset)   // index attached first, from the full list
itemsIndexed(upcoming, key = { _, item -> item.index }) { _, item ->
    onClick = { actions.onSeekToQueueIndex(item.index) }        // carried, not reconstructed
}

A stale or wrong offset can still cut the slice at the wrong place; carrying the index means it can no

longer make a visible row seek to a different track than the one it shows.

Verifying it

Build a queue that contains one track twice, start it, and turn shuffle on. Then check, in order:

the marker follows a natural track end; it follows a skip; tapping a row plays *that* row (not its

duplicate); dragging a row keeps the marker on the audible track. All four read the same value, so

a fix that passes only some of them is still in the wrong index space.

How to use it

Copy the folder

Take maxrave-dev/queue-index-vs-shuffle-space 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.