fix up-next drag and previous behaviour

- set lastPosition before seeking in jumpTo. With Dispatchers.Main
  .immediate the media transition runs synchronously inside the seek, so
  onPositionChanged saw the old position as an auto-advance and consumed
  the track you were leaving; previous from a queued track removed it and
  played the next one.
- give the up-next and context sections their own reorderable states. The
  library only targets items whose key is in that state's reorderableKeys,
  so a drag can no longer target a row in the other section: no more
  fighting the section boundary when dragging to the top and no no-op drop
  below the block.
- disable context reordering while shuffled via ReorderableItem(enabled).
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent e7a1927ccd
commit 5462b0f4ba
3 changed files with 123 additions and 5 deletions
@@ -870,12 +870,12 @@ class PlaybackController(
endedHandled = false endedHandled = false
return@withLock return@withLock
} }
lastPosition = dest
shiftWindowLocked(dest) shiftWindowLocked(dest)
player.ensurePrepared() player.ensurePrepared()
player.seekToIndex((dest - windowStart).toInt()) player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play() if (wasEnded) player.play()
endedHandled = false endedHandled = false
lastPosition = dest
refresh(dest) refresh(dest)
} }
} }
@@ -317,7 +317,11 @@ fun QueueScreen(
var dragId by remember { mutableStateOf<Long?>(null) } var dragId by remember { mutableStateOf<Long?>(null) }
var dragFrom by remember { mutableStateOf<Long?>(null) } var dragFrom by remember { mutableStateOf<Long?>(null) }
val reorderState = val upNextReorder =
rememberReorderableLazyListState(listState) { from, to ->
onReorder(from.index, to.index)
}
val contextReorder =
rememberReorderableLazyListState(listState) { from, to -> rememberReorderableLazyListState(listState) { from, to ->
onReorder(from.index, to.index) onReorder(from.index, to.index)
} }
@@ -378,10 +382,11 @@ fun QueueScreen(
if (index == 0 || rows[index - 1].upNext != row.upNext) { if (index == 0 || rows[index - 1].upNext != row.upNext) {
QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up") QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up")
} }
val draggable = row.upNext || !shuffle val enabled = row.upNext || !shuffle
ReorderableItem( ReorderableItem(
state = reorderState, state = if (row.upNext) upNextReorder else contextReorder,
key = row.id, key = row.id,
enabled = enabled,
animateItemModifier = animateItemModifier =
Modifier.animateItem( Modifier.animateItem(
fadeInSpec = tween(150), fadeInSpec = tween(150),
@@ -395,7 +400,7 @@ fun QueueScreen(
floating = isDragging, floating = isDragging,
coverArt = coverArt, coverArt = coverArt,
dragHandle = dragHandle =
if (!draggable) { if (!enabled) {
null null
} else { } else {
Modifier.draggableHandle( Modifier.draggableHandle(
@@ -612,6 +612,119 @@ class PlaybackControllerTest {
assertTrue(controller.state.value.layout > before) assertTrue(controller.state.value.layout > before)
} }
@Test
fun previousFromADeepQueuedTrackGoesBackWithoutConsuming() {
seedAlbum(3, sourceId = 1)
seedSong("q1", "al2")
seedSong("q2", "al2")
seedSong("q3", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "q1")
controller.addToQueue(1, QueueKind.Song, "q2")
controller.addToQueue(1, QueueKind.Song, "q3")
await { runBlocking { controller.upcomingItem()?.id } == "q1" }
controller.playAt(2)
await {
controller.state.value.item
?.id == "q2"
}
controller.previous()
await {
controller.state.value.item
?.id == "q1"
}
await { runBlocking { controller.upcomingItem()?.id } == "q2" }
}
@Test
fun previousFromAQueuedTrackDoesNotConsumeItWithAnEagerDispatcher() {
seedAlbum(3, sourceId = 1)
seedSong("q1", "al2")
seedSong("q2", "al2")
seedSong("q3", "al2")
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.Song, "q1")
eager.addToQueue(1, QueueKind.Song, "q2")
eager.addToQueue(1, QueueKind.Song, "q3")
await { runBlocking { eager.upcomingItem()?.id } == "q1" }
eager.playAt(2)
await {
eager.state.value.item
?.id == "q2"
}
eager.previous()
await {
eager.state.value.item
?.id == "q1"
}
Thread.sleep(300)
assertEquals(
"q1",
eager.state.value.item
?.id,
)
assertEquals("q2", runBlocking { eager.upcomingItem()?.id })
eager.close()
}
@Test
fun nextFollowsTheReorderedQueue() {
seedAlbum(3, sourceId = 1)
seedSong("q1", "al2")
seedSong("q2", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "q1")
controller.addToQueue(1, QueueKind.Song, "q2")
await { runBlocking { controller.upcomingItem()?.id } == "q1" }
runBlocking { controller.move(2, 1) }
await { runBlocking { controller.upcomingItem()?.id } == "q2" }
controller.next()
await {
controller.state.value.item
?.id == "q2"
}
controller.next()
await {
controller.state.value.item
?.id == "q1"
}
controller.next()
await {
controller.state.value.item
?.id == "s2"
}
}
@Test @Test
fun theDurationUsesTheNextTracksMetadataWhileItLoads() { fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
seedAlbum(3, sourceId = 1) seedAlbum(3, sourceId = 1)