mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
add a queue view with jump, remove and reorder
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user