stop locating the playing queue entry by song id

The queue can hold the same song more than once (a song in both the context
and the up-next block, a duplicate album track, ...), so resolving the
playing entry by song id could match the wrong copy.

- `PlaybackController.move` and `removeAt` now derive the current track's
  new position from the edit (block index / context play, read back through
  the updated snapshot so a moved anchor is accounted for) instead of
  `combinedIndexOf`/`combinedContextIndexOf`.
- the queue view's playing highlight compares the row position to the
  playback position instead of the song id, so duplicates no longer both
  light up.

Regression tests cover a move and a remove with a duplicate song in the
context; both fail without the change.

Still id-based, deliberately: `undo`'s canMirror guard (already position-
guarded), the shuffle-state-changed branch of `shiftWindowLocked` (rare,
needs the id to find the track after an arbitrary reorder) and the view's
LazyColumn keys (cosmetic only).
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 902f556fe6
commit e142148b5a
4 changed files with 122 additions and 45 deletions
@@ -490,6 +490,42 @@ class PlaybackControllerTest {
}
}
@Test
fun movingWithADuplicateInTheContextKeepsThePlayingCopy() {
seedAlbum(4, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"), queues.songEntry(1, "s2"))) }
controller.connect()
await { controller.state.value.item != null }
// Context is s1,s2,s3,s4,s2; play the second copy.
controller.playAt(4)
await { controller.state.value.position == 4L }
runBlocking { controller.move(0, 3) }
val snapshot = runBlocking { queues.snapshot() }
val playing = controller.state.value.position!!
assertTrue(runBlocking { queues.itemAt(snapshot, playing)?.song?.id } == "s2")
// Still the second copy (flat index 4), not the first.
assertEquals(4L, snapshot.flatContext(snapshot.contextPlay(playing)))
}
@Test
fun removingAnotherTrackWithADuplicateInTheContextKeepsThePlayingCopy() {
seedAlbum(4, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"), queues.songEntry(1, "s2"))) }
controller.connect()
await { controller.state.value.item != null }
controller.playAt(4)
await { controller.state.value.position == 4L }
runBlocking { controller.removeAt(0) }
val snapshot = runBlocking { queues.snapshot() }
val playing = controller.state.value.position!!
assertTrue(runBlocking { queues.itemAt(snapshot, playing)?.song?.id } == "s2")
assertEquals(3L, snapshot.flatContext(snapshot.contextPlay(playing)))
}
@Test
fun playNextFromInsideTheBlockGoesAfterTheCurrentTrack() {
seedAlbum(3, sourceId = 1)
@@ -1637,6 +1673,29 @@ class PlaybackControllerTest {
)
}
@Test
fun removingThePlayingAnchorWhileShuffledPlaysTheQueuedTrackNext() {
seedAlbum(6, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.toggleShuffle()
await { controller.state.value.shuffle }
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
val current = controller.state.value.position!!
runBlocking { controller.removeAt(current) }
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
}
@Test
fun removingThePlayingTrackWhileShuffledLeavesTheBlockReachable() {
seedAlbum(6, sourceId = 1)
@@ -415,7 +415,7 @@ class AppScreenshotTest {
rows =
Fixtures.songItems.mapIndexed { index, item -> QueueRow(index.toLong(), index.toLong(), item) },
ready = true,
currentSongId = Fixtures.songItems[1].song.id,
currentPosition = 1L,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onPlay = {},