keep lastPosition in sync when a track move or delete shifts the current

move/removeAt/undo (and the window shift) changed the current track's
position without updating lastPosition, so the next transition compared
against a stale position and consumed whatever now sat there — moving a
queued track in front of the playing one deleted the moved track on the
following skip. Added an eager-dispatcher regression test.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent e23f78aa47
commit 3e459e4c84
2 changed files with 94 additions and 0 deletions
@@ -337,6 +337,7 @@ class PlaybackController(
if (wasUpNext) queueRepository.combinedIndexOf(updated, it) else queueRepository.combinedContextIndexOf(updated, it)
} ?: (if (position < current) current - 1 else current)
).coerceIn(0, updated.size - 1)
lastPosition = target
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
@@ -390,6 +391,7 @@ class PlaybackController(
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = readSnapshot()
val target = movedCursor(current, from, to)
lastPosition = target
windowJob?.cancel()
val fromInWindow = from in windowStart..windowEnd
val toInWindow = to in windowStart..windowEnd
@@ -447,6 +449,7 @@ class PlaybackController(
return@withLock
}
val target = undo.cursor.coerceIn(0, restored.size - 1)
lastPosition = target
val restoredId = queueRepository.itemAt(restored, target)?.song?.id
windowJob?.cancel()
val canMirror =
@@ -951,6 +954,7 @@ class PlaybackController(
} finally {
updating = false
}
lastPosition = target
}
private fun rebuildWindow(
@@ -733,6 +733,96 @@ class PlaybackControllerTest {
eager.close()
}
@Test
fun movingAQueuedTrackFromAnotherAlbumBeforeTheCurrentDoesNotConsumeIt() {
seedAlbum(3, sourceId = 1)
seedSong("b1", "al2")
seedSong("b2", "al2")
seedSong("c1", "al3")
val eagerHandle = FakePlayerHandle()
val inline =
object : kotlinx.coroutines.CoroutineDispatcher() {
override fun dispatch(
context: kotlin.coroutines.CoroutineContext,
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
eager.state.value.item
?.id == "s1"
}
eager.addToQueue(1, QueueKind.Album, "al2")
eager.addToQueue(1, QueueKind.Song, "c1")
await { runBlocking { eager.upcomingItem()?.id } == "b1" }
eager.playAt(2)
await {
eager.state.value.item
?.id == "b2"
}
runBlocking { eager.move(3, 2) }
Thread.sleep(300)
assertEquals(
"b2",
eager.state.value.item
?.id,
)
assertEquals(3L, runBlocking { queues.snapshot().upNextSize })
eager.close()
}
@Test
fun movingAQueuedTrackBeforeTheCurrentConsumesTheRightTrackOnSkip() {
seedAlbum(3, sourceId = 1)
seedSong("b1", "al2")
seedSong("b2", "al2")
seedSong("c1", "al3")
val eagerHandle = FakePlayerHandle()
val inline =
object : kotlinx.coroutines.CoroutineDispatcher() {
override fun dispatch(
context: kotlin.coroutines.CoroutineContext,
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
eager.state.value.item
?.id == "s1"
}
eager.addToQueue(1, QueueKind.Album, "al2")
eager.addToQueue(1, QueueKind.Song, "c1")
await { runBlocking { eager.upcomingItem()?.id } == "b1" }
eager.playAt(1)
await {
eager.state.value.item
?.id == "b1"
}
runBlocking { eager.move(3, 1) }
await {
eager.state.value.item
?.id == "b1"
}
eagerHandle.advanceTo(eagerHandle.currentIndex + 1)
await {
eager.state.value.item
?.id == "b2"
}
val queued = runBlocking { queues.snapshot().upNext.map { it.entry.refId } }
assertTrue("c1" in queued)
eager.close()
}
@Test
fun nextFollowsTheReorderedQueue() {
seedAlbum(3, sourceId = 1)