avoid reloading the window on queue edits; add clear up next

- moves that straddle the player window, and deletes that are not the
  current track, rebuilt the playlist with setWindow, restarting the
  playing track (a pause). They now reuse rebuildWindow, which re-lays
  the window around the playing item without replacing it.
- deleting the currently playing queued track now removes it from the
  player in place so it advances to the next track without a reload.
- the Up next section header has a clear button that drops the whole
  queued block and resumes the context after the anchor.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent bff16e0dd1
commit 996b0f8f48
4 changed files with 175 additions and 11 deletions
@@ -378,6 +378,10 @@ class QueueRepository(
return Reanchored(play, true)
}
suspend fun clearUpNext() {
writeUpNext(emptyList())
}
suspend fun removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
@@ -346,7 +346,19 @@ class PlaybackController(
}
if (snapshot.upNextSize > 0L || updated.upNextSize > 0L) {
queueRepository.setCursor(target)
loadWindow(target, autoplay = playing, startPositionMs = if (position == current) 0 else player.currentPositionMs)
if (position != current) {
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
refresh(target)
} else if (target - windowStart in 0 until player.itemCount) {
updating = true
player.removeAt((target - windowStart).toInt())
updating = false
windowEnd--
shiftWindowLocked(target)
refresh(target)
} else {
loadWindow(target, autoplay = playing)
}
return@withLock
}
when {
@@ -389,7 +401,8 @@ class PlaybackController(
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = readSnapshot()
val updated = readSnapshot()
this.snapshot = updated
val target = movedCursor(current, from, to)
lastPosition = target
windowJob?.cancel()
@@ -425,7 +438,7 @@ class PlaybackController(
}
else -> {
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
}
}
shiftWindowLocked(target)
@@ -567,6 +580,34 @@ class PlaybackController(
}
}
fun clearUpNext() {
scope.launch {
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (snapshot.upNextSize == 0L) return@withLock
player ?: return@withLock
val current = currentPosition() ?: return@withLock
val wasUpNext = snapshot.isUpNext(current)
queueRepository.clearUpNext()
val updated = readSnapshot()
this@PlaybackController.snapshot = updated
layoutVersion++
windowJob?.cancel()
endedHandled = false
val contextPlay = if (wasUpNext) snapshot.anchorPlay + 1 else snapshot.contextPlay(current)
val target = contextPlay.coerceIn(0, (updated.size - 1).coerceAtLeast(0))
queueRepository.setCursor(target)
lastPosition = target
if (wasUpNext) {
loadWindow(target, autoplay = true)
} else {
rebuildWindow(queueRepository.window(updated, target, QUEUE_WINDOW_RADIUS), target)
refresh(target)
}
}
}
}
fun previous() {
val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
@@ -299,6 +299,7 @@ fun QueueRoute(
currentSongId = playback.item?.id,
shuffle = playback.shuffle,
contextTitle = sourceTitle.value,
onClearUpNext = controller::clearUpNext,
coverArt = sourceRepository::coverArt,
onBack = onBack,
onPlay = viewModel::play,
@@ -332,6 +333,7 @@ fun QueueScreen(
generation: Int = 0,
shuffle: Boolean = false,
contextTitle: String? = null,
onClearUpNext: () -> Unit = {},
) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
@@ -403,7 +405,10 @@ fun QueueScreen(
rows.forEachIndexed { index, row ->
if (index == 0 || rows[index - 1].upNext != row.upNext) {
item(key = "header-$index") {
QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up")
QueueSectionHeader(
label = if (row.upNext) "Up next" else contextTitle ?: "Next up",
onClear = if (row.upNext) onClearUpNext else null,
)
}
}
val enabled = row.upNext || !shuffle
@@ -471,13 +476,26 @@ fun QueueScreen(
}
@Composable
private fun QueueSectionHeader(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
private fun QueueSectionHeader(
label: String,
onClear: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, end = 4.dp, bottom = 4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
if (onClear != null) {
IconButton(onClick = onClear) {
Icon(Icons.Rounded.Close, contentDescription = "Clear up next", modifier = Modifier.size(20.dp))
}
}
}
}
private fun List<QueueRow>.rowIndexOf(key: Any?): Int {
@@ -851,6 +851,81 @@ class PlaybackControllerTest {
assertFalse(handle.operations.any { it.startsWith("setWindow") })
}
@Test
fun clearingTheQueueResumesTheContext() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.clearUpNext()
await {
controller.state.value.item
?.id == "s2"
}
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun clearingTheQueueWhileOnTheContextKeepsPlaying() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.clearUpNext()
Thread.sleep(200)
assertEquals(
"s1",
controller.state.value.item
?.id,
)
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun movingAFarQueuedTrackDoesNotReloadTheWindow() {
seedAlbum(3, sourceId = 1)
seedAlbumTracks("al2", 60)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Album, "al2")
await { runBlocking { controller.upcomingItem()?.id } == "al2-b1" }
handle.operations.clear()
runBlocking { controller.move(50, 2) }
Thread.sleep(300)
assertEquals(
"s1",
controller.state.value.item
?.id,
)
assertFalse(handle.operations.any { it.startsWith("setWindow") })
}
@Test
fun nextFollowsTheReorderedQueue() {
seedAlbum(3, sourceId = 1)
@@ -1846,6 +1921,32 @@ class PlaybackControllerTest {
}
}
private fun seedAlbumTracks(
albumId: String,
count: Int,
) {
runBlocking {
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "$albumId-b$track",
albumId = albumId,
artistId = "ar1",
title = "Album $albumId track $track",
album = "Album $albumId",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
}
private fun seedSong(
id: String,
albumId: String,