add a separate, editable up-next queue (#61)

Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.

PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 50b99979e1
commit d5a9a11cef
18 changed files with 2038 additions and 145 deletions
@@ -372,6 +372,142 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size)
}
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
}
@Test
fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = true,
)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingAQueuedTrackDropsItFromTheCombinedQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.removeAt(repository.snapshot(), 1)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(updated))
}
@Test
fun movingAQueuedTrackReordersTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = false,
)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.move(repository.snapshot(), from = 2, to = 1)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun queuedTracksNeverJoinAShuffledContext() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, longArrayOf(2, 0, 1))
val shuffled = repository.snapshot()
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals("s5", repository.itemAt(updated, 1)?.song?.id)
assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated))
assertTrue(updated.shuffled)
}
@Test
fun consumingASliceOfAQueuedAlbumRemovesTheRightTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingTheLastAnchorTrackKeepsTheQueueResolvable() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s4"), resolveAll(updated))
}
@Test
fun removingAContextTrackBeforeTheAnchorKeepsTheQueueAttached() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
assertEquals(listOf("s2", "s4", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun replacingTheContextClearsTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.replace(listOf(repository.songEntry(1, "s5")))
val updated = repository.snapshot()
assertTrue(updated.upNext.isEmpty())
assertEquals(1, updated.size)
assertEquals(listOf("s5"), resolveAll(updated))
}
private fun albumRange(
start: Long,
end: Long,
@@ -183,8 +183,10 @@ class FakePlayerHandle : PlayerHandle {
override fun seekToIndex(index: Int) {
operations += "seekToIndex($index)"
val changed = index != this.index
this.index = index
ended = false
if (changed) listeners.forEach { it.onTransition() }
notifyEvents()
}
@@ -192,6 +192,167 @@ class PlaybackControllerTest {
assertTrue(controller.state.value.hasPrevious)
}
@Test
fun aQueuedTrackPlaysNextAndIsConsumed() {
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.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s2"
}
assertEquals("s3", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun addingToTheQueueFromAMiddleTrackPlaysTheQueuedTrackNext() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s3"
}
}
@Test
fun playNextGoesInFrontOfTheQueuedTracks() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
seedSong("x2", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.playNext(1, QueueKind.Song, "x2")
await { runBlocking { controller.upcomingItem()?.id } == "x2" }
controller.next()
await {
controller.state.value.item
?.id == "x2"
}
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
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.playAt(3)
await {
controller.state.value.item
?.id == "s3"
}
assertFalse(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun previousFromAQueuedTrackReturnsToTheContextWithoutConsuming() {
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.previous()
await {
controller.state.value.item
?.id == "s1"
}
assertEquals("x1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun startingANewContextClearsTheQueue() {
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.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
seedAlbum(3, sourceId = 1)
@@ -1152,6 +1313,32 @@ class PlaybackControllerTest {
}
}
private fun seedSong(
id: String,
albumId: String,
) {
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = "ar1",
title = "Song $id",
album = "Other Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
}
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)