add a queue view with jump, remove and reorder

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 853f37f14d
commit e87d176155
18 changed files with 941 additions and 44 deletions
@@ -0,0 +1,104 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QueuePagingSourceTest {
private lateinit var db: SubtracksDatabase
private lateinit var repository: QueueRepository
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = QueueRepository(db)
}
@After
fun tearDown() {
db.close()
}
@Test
fun pagesAreContiguousAndDoNotOverlap() =
runTest {
seedSongs(130)
repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource()
val first = load(source, key = null)
assertEquals((0L until 60L).toList(), first.data.map { it.position })
assertEquals(1L, first.nextKey)
assertEquals(null, first.prevKey)
val second = load(source, key = 1L)
assertEquals((60L until 120L).toList(), second.data.map { it.position })
assertEquals(2L, second.nextKey)
assertEquals(0L, second.prevKey)
val third = load(source, key = 2L)
assertEquals((120L until 130L).toList(), third.data.map { it.position })
assertEquals(null, third.nextKey)
assertEquals(1L, third.prevKey)
}
@Test
fun anEmptyQueueLoadsNothing() =
runTest {
val result = load(repository.pagingSource(), key = null)
assertEquals(emptyList<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey)
assertEquals(null, result.prevKey)
}
private suspend fun load(
source: PagingSource<Long, QueueWindowItem>,
key: Long?,
): PagingSource.LoadResult.Page<Long, QueueWindowItem> {
val params = PagingSource.LoadParams.Refresh<Long>(key, QUEUE_PAGE_SIZE, false)
return source.load(params) as PagingSource.LoadResult.Page<Long, QueueWindowItem>
}
private suspend fun seedSongs(count: Int) {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
}
@@ -228,6 +228,77 @@ class QueueRepositoryTest {
assertEquals(listOf("s4"), resolveAll(snapshot))
}
@Test
fun rangeResolvesASliceSpanningEntries() =
runTest {
seedLibrary()
val entries = listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))
val snapshot = repository.snapshotAfter(entries)
val range = repository.range(snapshot, first = 1, last = 3)
assertEquals(listOf(1L, 2L, 3L), range.map { it.position })
assertEquals(listOf("s2", "s3", "s4"), range.map { it.item.song.id })
}
@Test
fun removingInsideAnEntrySplitsItIntoTwoRanges() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(snapshot, 1)
assertEquals(listOf("s1", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingTheFirstOrLastTrackKeepsTheRemainingRange() =
runTest {
seedLibrary()
val first = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(first, 0)
assertEquals(listOf("s2", "s3"), resolveAll(repository.snapshot()))
val last = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(last, 2)
assertEquals(listOf("s1", "s2"), resolveAll(repository.snapshot()))
}
@Test
fun removingASongEntryDropsIt() =
runTest {
seedLibrary()
val snapshot =
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.removeAt(snapshot, 3)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun movingWithinAnEntryReordersTheQueue() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.move(from = 0, to = 2)
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
}
@Test
fun movingAcrossEntriesKeepsTheRestInOrder() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.move(from = 0, to = 3)
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
}
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
replace(entries)
return snapshot()
@@ -61,8 +61,9 @@ class FakePlayerHandle : PlayerHandle {
override fun setWindow(
items: List<QueueItem>,
startIndex: Int,
startPositionMs: Long,
) {
operations += "setWindow(size=${items.size}, start=$startIndex)"
operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)"
this.items.clear()
this.items += items
index = startIndex
@@ -96,6 +97,34 @@ class FakePlayerHandle : PlayerHandle {
items.removeAt(items.size - 1)
}
override fun removeAt(index: Int) {
operations += "removeAt($index)"
items.removeAt(index)
this.index =
when {
index < this.index -> this.index - 1
index == this.index -> this.index.coerceAtMost(items.size - 1)
else -> this.index
}
notifyEvents()
}
override fun move(
from: Int,
to: Int,
) {
operations += "move($from, $to)"
items.add(to, items.removeAt(from))
index =
when {
index == from -> to
from < index && to >= index -> index - 1
from > index && to <= index -> index + 1
else -> index
}
notifyEvents()
}
override fun prepare() {
operations += "prepare"
idle = false
@@ -311,6 +311,132 @@ class PlaybackControllerTest {
assertFalse(controller.state.value.hasPrevious)
}
@Test
fun jumpingToAVisibleItemSeeksWithinTheWindow() {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.playAt(10)
await { controller.state.value.position == 10L }
assertTrue(handle.operations.contains("seekToIndex(10)"))
assertEquals(
"s11",
controller.state.value.item
?.id,
)
}
@Test
fun jumpingOutsideTheWindowReloadsAroundTheTarget() {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.playAt(40)
await {
controller.state.value.item
?.id == "s41"
}
assertEquals(40L, controller.state.value.position)
assertTrue(handle.operations.any { it.startsWith("setWindow") })
assertTrue(controller.state.value.hasPrevious)
}
@Test
fun removingTheCurrentTrackPlaysTheNextOne() {
seedAlbum(5, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.removeAt(0) }
await {
controller.state.value.item
?.id == "s2"
}
assertEquals(0L, controller.state.value.position)
assertTrue(handle.operations.contains("removeAt(0)"))
assertFalse(controller.state.value.hasPrevious)
}
@Test
fun removingALaterTrackKeepsTheCurrentOne() {
seedAlbum(5, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.removeAt(3) }
await { handle.itemCount == 4 }
assertEquals(
"s1",
controller.state.value.item
?.id,
)
assertEquals(listOf("s1", "s2", "s3", "s5"), handle.items.map { it.id })
}
@Test
fun movingATrackReordersTheWindow() {
seedAlbum(4, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.move(0, 2) }
await { controller.state.value.position == 2L }
assertEquals(listOf("s2", "s3", "s1", "s4"), handle.items.map { it.id })
assertEquals(
"s1",
controller.state.value.item
?.id,
)
}
@Test
fun movingALaterTrackBeforeTheCurrentShiftsTheCursor() {
seedAlbum(4, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
runBlocking { controller.move(3, 0) }
await { controller.state.value.position == 3L }
assertEquals(listOf("s4", "s1", "s2", "s3"), handle.items.map { it.id })
assertEquals(
"s3",
controller.state.value.item
?.id,
)
}
private fun seedAlbum(
count: Int,
sourceId: Long,
@@ -34,6 +34,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.ui.library.ALBUM_COVER_TAG
@@ -43,6 +44,7 @@ import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.PlaylistDetailScreen
import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingScreen
import com.subtracks.ui.playback.QueueScreen
import com.subtracks.ui.settings.AddSourceScreen
import com.subtracks.ui.settings.AddSourceState
import com.subtracks.ui.settings.SettingsScreen
@@ -203,6 +205,7 @@ class AppScreenshotTest {
state = Fixtures.playbackState(),
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
onBack = {},
onQueue = {},
onPlayPause = {},
onNext = {},
onPrevious = {},
@@ -215,6 +218,33 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
}
@Test
fun queue() {
composeRule.setContent {
SubtracksTheme {
QueueScreen(
items =
remember {
flowOf(
PagingData.from(
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
),
)
}.collectAsLazyPagingItems(),
currentPosition = 1,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onPlay = {},
onRemove = {},
onMove = { _, _ -> },
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/queue.png")
}
@Test
fun miniPlayer() {
composeRule.setContent {