mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user