mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
bound the queue view and compact edited queue ranges
This commit is contained in:
@@ -13,10 +13,21 @@ import com.subtracks.data.model.SongListItem
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
const val QUEUE_CHUNK = 60
|
const val QUEUE_CHUNK = 60
|
||||||
|
|
||||||
|
private const val LENGTH_CACHE_LIMIT = 512
|
||||||
|
|
||||||
|
private data class EntryLengthKey(
|
||||||
|
val sourceId: Long,
|
||||||
|
val kind: QueueKind,
|
||||||
|
val refId: String,
|
||||||
|
val offset: Long,
|
||||||
|
val count: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
data class ResolvedQueueEntry(
|
data class ResolvedQueueEntry(
|
||||||
val entry: QueueEntry,
|
val entry: QueueEntry,
|
||||||
val length: Long,
|
val length: Long,
|
||||||
@@ -74,6 +85,8 @@ class QueueRepository(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var cachedFlatVersion = -1L
|
private var cachedFlatVersion = -1L
|
||||||
|
|
||||||
|
private val lengthCache = ConcurrentHashMap<EntryLengthKey, Long>()
|
||||||
|
|
||||||
private val cursorMutex = Mutex()
|
private val cursorMutex = Mutex()
|
||||||
|
|
||||||
fun playlistEntry(
|
fun playlistEntry(
|
||||||
@@ -134,7 +147,7 @@ class QueueRepository(
|
|||||||
val loaded =
|
val loaded =
|
||||||
db.useReaderConnection { transactor ->
|
db.useReaderConnection { transactor ->
|
||||||
transactor.deferredTransaction {
|
transactor.deferredTransaction {
|
||||||
val entries = dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }
|
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it)) }
|
||||||
val row = dao.cursor()
|
val row = dao.cursor()
|
||||||
val shuffled = row?.shuffleEnabled == true
|
val shuffled = row?.shuffleEnabled == true
|
||||||
val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
|
val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
|
||||||
@@ -173,6 +186,7 @@ class QueueRepository(
|
|||||||
|
|
||||||
fun invalidateLibraryCache() {
|
fun invalidateLibraryCache() {
|
||||||
cachedFlatIds = null
|
cachedFlatIds = null
|
||||||
|
lengthCache.clear()
|
||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +286,11 @@ class QueueRepository(
|
|||||||
): QueueSnapshot {
|
): QueueSnapshot {
|
||||||
val order = snapshot.shuffleOrder
|
val order = snapshot.shuffleOrder
|
||||||
if (order == null) {
|
if (order == null) {
|
||||||
write(removeEntry(snapshot.entries, position).map { it.entry })
|
write(compact(removeEntry(snapshot.entries, position)).map { it.entry })
|
||||||
return snapshot()
|
return snapshot()
|
||||||
}
|
}
|
||||||
val flat = order.getOrNull(position.toInt()) ?: return snapshot
|
val flat = order.getOrNull(position.toInt()) ?: return snapshot
|
||||||
write(removeEntry(snapshot.entries, flat).map { it.entry })
|
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
|
||||||
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
|
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
|
||||||
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
|
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
|
||||||
setShuffle(true, reordered.toLongArray())
|
setShuffle(true, reordered.toLongArray())
|
||||||
@@ -299,8 +313,8 @@ class QueueRepository(
|
|||||||
val located = snapshot.locate(from) ?: return false
|
val located = snapshot.locate(from) ?: return false
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val removed = removeEntry(snapshot.entries, from)
|
val removed = removeEntry(snapshot.entries, from)
|
||||||
val moved = songEntry(located.first.sourceId, song.song.id)
|
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
||||||
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
|
write(compact(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved)).map { it.entry })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +386,24 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun lengthOf(entry: QueueEntry): Long {
|
||||||
|
val key = entry.lengthKey()
|
||||||
|
lengthCache[key]?.let { return it }
|
||||||
|
val length = entry.resolvedLength()
|
||||||
|
if (lengthCache.size >= LENGTH_CACHE_LIMIT) lengthCache.clear()
|
||||||
|
lengthCache[key] = length
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.lengthKey() =
|
||||||
|
EntryLengthKey(
|
||||||
|
sourceId = sourceId,
|
||||||
|
kind = kind,
|
||||||
|
refId = refId,
|
||||||
|
offset = offset,
|
||||||
|
count = count,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
val total =
|
val total =
|
||||||
when (kind) {
|
when (kind) {
|
||||||
@@ -425,9 +457,9 @@ class QueueRepository(
|
|||||||
private fun insertEntry(
|
private fun insertEntry(
|
||||||
entries: List<ResolvedQueueEntry>,
|
entries: List<ResolvedQueueEntry>,
|
||||||
index: Long,
|
index: Long,
|
||||||
newEntry: QueueEntry,
|
newEntry: ResolvedQueueEntry,
|
||||||
): List<QueueEntry> {
|
): List<ResolvedQueueEntry> {
|
||||||
val result = mutableListOf<QueueEntry>()
|
val result = mutableListOf<ResolvedQueueEntry>()
|
||||||
var start = 0L
|
var start = 0L
|
||||||
var placed = false
|
var placed = false
|
||||||
for (resolved in entries) {
|
for (resolved in entries) {
|
||||||
@@ -435,21 +467,29 @@ class QueueRepository(
|
|||||||
when {
|
when {
|
||||||
!placed && index <= start -> {
|
!placed && index <= start -> {
|
||||||
result += newEntry
|
result += newEntry
|
||||||
result += resolved.entry
|
result += resolved
|
||||||
placed = true
|
placed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
!placed && index in start..end -> {
|
!placed && index in start..end -> {
|
||||||
val cut = index - start
|
val cut = index - start
|
||||||
val offset = resolved.entry.offset
|
val offset = resolved.entry.offset
|
||||||
if (cut > 0) result += resolved.entry.range(offset, offset + cut - 1)
|
if (cut > 0) {
|
||||||
|
result += ResolvedQueueEntry(resolved.entry.range(offset, offset + cut - 1), cut)
|
||||||
|
}
|
||||||
result += newEntry
|
result += newEntry
|
||||||
if (cut < resolved.length) result += resolved.entry.range(offset + cut, offset + resolved.length - 1)
|
if (cut < resolved.length) {
|
||||||
|
result +=
|
||||||
|
ResolvedQueueEntry(
|
||||||
|
resolved.entry.range(offset + cut, offset + resolved.length - 1),
|
||||||
|
resolved.length - cut,
|
||||||
|
)
|
||||||
|
}
|
||||||
placed = true
|
placed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
result += resolved.entry
|
result += resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start += resolved.length
|
start += resolved.length
|
||||||
@@ -457,4 +497,27 @@ class QueueRepository(
|
|||||||
if (!placed) result += newEntry
|
if (!placed) result += newEntry
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun compact(entries: List<ResolvedQueueEntry>): List<ResolvedQueueEntry> {
|
||||||
|
val merged = mutableListOf<ResolvedQueueEntry>()
|
||||||
|
for (resolved in entries) {
|
||||||
|
val previous = merged.lastOrNull()
|
||||||
|
if (previous != null && previous.contiguousWith(resolved)) {
|
||||||
|
merged[merged.size - 1] =
|
||||||
|
ResolvedQueueEntry(
|
||||||
|
previous.entry.range(previous.entry.offset, resolved.entry.offset + resolved.length - 1),
|
||||||
|
previous.length + resolved.length,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
merged += resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResolvedQueueEntry.contiguousWith(next: ResolvedQueueEntry): Boolean =
|
||||||
|
entry.kind == next.entry.kind &&
|
||||||
|
entry.sourceId == next.entry.sourceId &&
|
||||||
|
entry.refId == next.entry.refId &&
|
||||||
|
entry.offset + length == next.entry.offset
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ private const val LOAD_THRESHOLD = 5
|
|||||||
private const val OLDER_LOAD = 30
|
private const val OLDER_LOAD = 30
|
||||||
private const val POSITION_TIMEOUT_MS = 1_000L
|
private const val POSITION_TIMEOUT_MS = 1_000L
|
||||||
|
|
||||||
|
internal const val QUEUE_WINDOW_ROWS = QUEUE_CHUNK * 3
|
||||||
|
|
||||||
data class QueueRow(
|
data class QueueRow(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val position: Long,
|
val position: Long,
|
||||||
@@ -92,9 +94,6 @@ class QueueViewModel(
|
|||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
private var size = 0L
|
|
||||||
private var first = 0L
|
|
||||||
private var last = -1L
|
|
||||||
|
|
||||||
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item)
|
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item)
|
||||||
|
|
||||||
@@ -114,21 +113,16 @@ class QueueViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
|
||||||
val reused = reusedIds()
|
val reused = reusedIds()
|
||||||
rows.clear()
|
rows.clear()
|
||||||
first = 0
|
if (snapshot.size > 0L) {
|
||||||
last = -1
|
|
||||||
if (size > 0L) {
|
|
||||||
val cursor =
|
val cursor =
|
||||||
withTimeoutOrNull(POSITION_TIMEOUT_MS) {
|
withTimeoutOrNull(POSITION_TIMEOUT_MS) {
|
||||||
playbackController.state.first { it.position != null }.position
|
playbackController.state.first { it.position != null }.position
|
||||||
}?.coerceIn(0, size - 1) ?: 0L
|
}?.coerceIn(0, snapshot.size - 1) ?: 0L
|
||||||
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1)
|
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
|
||||||
rows.addAll(queueRepository.range(snapshot, start, end).map { rowFor(it, reused) })
|
rows.addAll(queueRepository.range(snapshot, start, end).map { rowFor(it, reused) })
|
||||||
first = rows.firstOrNull()?.position ?: 0
|
|
||||||
last = rows.lastOrNull()?.position ?: -1
|
|
||||||
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
ready = true
|
ready = true
|
||||||
@@ -138,34 +132,33 @@ class QueueViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadOlder() {
|
fun loadOlder() {
|
||||||
if (first <= 0L) return
|
val top = rows.firstOrNull()?.position ?: return
|
||||||
|
if (top <= 0L) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (first <= 0L || rows.isEmpty()) return@withLock
|
val first = rows.firstOrNull()?.position ?: return@withLock
|
||||||
|
if (first <= 0L) return@withLock
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
val loaded = queueRepository.range(snapshot, (first - QUEUE_CHUNK).coerceAtLeast(0), first - 1)
|
||||||
val from = (first - QUEUE_CHUNK).coerceAtLeast(0)
|
|
||||||
val loaded = queueRepository.range(snapshot, from, first - 1)
|
|
||||||
if (loaded.isEmpty()) return@withLock
|
if (loaded.isEmpty()) return@withLock
|
||||||
rows.addAll(0, loaded.map(::newRow))
|
rows.addAll(0, loaded.map(::newRow))
|
||||||
first = rows.first().position
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadNewer() {
|
fun loadNewer() {
|
||||||
if (rows.isEmpty() || last >= size - 1) return
|
if (rows.lastOrNull() == null) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (rows.isEmpty() || last >= size - 1) return@withLock
|
val last = rows.lastOrNull()?.position ?: return@withLock
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
if (last >= snapshot.size - 1) return@withLock
|
||||||
if (last >= size - 1) return@withLock
|
val to = (last + QUEUE_CHUNK).coerceAtMost(snapshot.size - 1)
|
||||||
val to = (last + QUEUE_CHUNK).coerceAtMost(size - 1)
|
|
||||||
val loaded = queueRepository.range(snapshot, last + 1, to)
|
val loaded = queueRepository.range(snapshot, last + 1, to)
|
||||||
if (loaded.isEmpty()) return@withLock
|
if (loaded.isEmpty()) return@withLock
|
||||||
rows.addAll(loaded.map(::newRow))
|
rows.addAll(loaded.map(::newRow))
|
||||||
last = rows.last().position
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,12 +184,15 @@ class QueueViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.move(from, to)
|
playbackController.move(from, to)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
val base = rows.firstOrNull()?.position ?: return@withLock
|
||||||
// The loaded order already reflects the move, so only the positions need updating.
|
// The loaded order already reflects the move, so only the positions need updating.
|
||||||
for (i in rows.indices) {
|
val updated =
|
||||||
val row = rows[i]
|
rows.mapIndexed { index, row ->
|
||||||
val position = first + i
|
val position = base + index
|
||||||
if (row.position != position) rows[i] = row.copy(position = position)
|
if (row.position == position) row else row.copy(position = position)
|
||||||
}
|
}
|
||||||
|
rows.clear()
|
||||||
|
rows.addAll(updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,18 +210,15 @@ class QueueViewModel(
|
|||||||
}
|
}
|
||||||
playbackController.removeAt(position)
|
playbackController.removeAt(position)
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
reload()
|
reload()
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
first = rows.first().position
|
val next = rows.last().position + 1
|
||||||
last = rows.last().position
|
if (next <= snapshot.size - 1) {
|
||||||
val next = last + 1
|
|
||||||
if (next <= size - 1) {
|
|
||||||
queueRepository.range(snapshot, next, next).firstOrNull()?.let { rows.add(newRow(it)) }
|
queueRepository.range(snapshot, next, next).firstOrNull()?.let { rows.add(newRow(it)) }
|
||||||
last = rows.last().position
|
|
||||||
}
|
}
|
||||||
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,22 +232,17 @@ class QueueViewModel(
|
|||||||
|
|
||||||
private suspend fun reload() {
|
private suspend fun reload() {
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
size = snapshot.size
|
if (snapshot.size == 0L) {
|
||||||
if (size == 0L) {
|
|
||||||
rows.clear()
|
rows.clear()
|
||||||
first = 0
|
|
||||||
last = -1
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, size - 1)
|
val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, snapshot.size - 1)
|
||||||
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1)
|
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(snapshot.size - 1)
|
||||||
val loaded = queueRepository.range(snapshot, start, end)
|
val loaded = queueRepository.range(snapshot, start, end)
|
||||||
val reused = reusedIds()
|
val reused = reusedIds()
|
||||||
val updated = loaded.map { rowFor(it, reused) }
|
val updated = loaded.map { rowFor(it, reused) }
|
||||||
rows.clear()
|
rows.clear()
|
||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
first = rows.firstOrNull()?.position ?: 0
|
|
||||||
last = rows.lastOrNull()?.position ?: -1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,84 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removingASongBetweenContiguousRangesMergesThem() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(
|
||||||
|
listOf(
|
||||||
|
albumRange(0, 0),
|
||||||
|
repository.songEntry(1, "s4"),
|
||||||
|
albumRange(1, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
|
repository.removeAt(repository.snapshot(), 1)
|
||||||
|
|
||||||
|
val compacted = repository.snapshot()
|
||||||
|
assertEquals(1, compacted.entries.size)
|
||||||
|
assertEquals(listOf("s1", "s2", "s3"), resolveAll(compacted))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangesSeparatedByAGapAreNotMerged() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(
|
||||||
|
listOf(
|
||||||
|
albumRange(0, 0),
|
||||||
|
repository.songEntry(1, "s4"),
|
||||||
|
albumRange(2, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(listOf("s1", "s4", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
|
repository.removeAt(repository.snapshot(), 1)
|
||||||
|
|
||||||
|
val kept = repository.snapshot()
|
||||||
|
assertEquals(2, kept.entries.size)
|
||||||
|
assertEquals(listOf("s1", "s3"), resolveAll(kept))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingATrackOutAndBackCompactsTheQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
|
||||||
|
|
||||||
|
repository.move(repository.snapshot(), from = 3, to = 1)
|
||||||
|
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||||
|
|
||||||
|
repository.move(repository.snapshot(), from = 1, to = 3)
|
||||||
|
|
||||||
|
val compacted = repository.snapshot()
|
||||||
|
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(compacted))
|
||||||
|
assertEquals(2, compacted.entries.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolvedLengthsSurviveQueueEditsUntilTheLibraryIsInvalidated() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s5")))
|
||||||
|
assertEquals(4, repository.snapshot().size)
|
||||||
|
|
||||||
|
repository.removeAt(repository.snapshot(), 3)
|
||||||
|
assertEquals(3, repository.snapshot().size)
|
||||||
|
|
||||||
|
db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album")))
|
||||||
|
assertEquals(3, repository.snapshot().size)
|
||||||
|
|
||||||
|
repository.invalidateLibraryCache()
|
||||||
|
assertEquals(4, repository.snapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun albumRange(
|
||||||
|
start: Long,
|
||||||
|
end: Long,
|
||||||
|
) = QueueEntry(position = 0, sourceId = 1, kind = QueueKind.Album, refId = "al1", rangeStart = start, rangeEnd = end)
|
||||||
|
|
||||||
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
||||||
replace(entries)
|
replace(entries)
|
||||||
return snapshot()
|
return snapshot()
|
||||||
@@ -316,6 +394,7 @@ class QueueRepositoryTest {
|
|||||||
assertTrue(repository.snapshot().shuffled)
|
assertTrue(repository.snapshot().shuffled)
|
||||||
|
|
||||||
db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album")))
|
db.libraryDao().upsertSongs(listOf(song("s6", "al1", track = 4, album = "First Album")))
|
||||||
|
repository.invalidateLibraryCache()
|
||||||
|
|
||||||
assertFalse(repository.snapshot().shuffled)
|
assertFalse(repository.snapshot().shuffled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,138 @@ class QueueViewModelTest {
|
|||||||
assertEquals(listOf(71L, 72L, 70L), positions.take(3))
|
assertEquals(listOf(71L, 72L, 70L), positions.take(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollingNewerEvictsOlderRowsToBoundTheWindow() {
|
||||||
|
runBlocking { seedSongs(400) }
|
||||||
|
controller.playAlbum(1, "al1", 100)
|
||||||
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
assertEquals((70L..129L).toList(), viewModel.rows.map { it.position })
|
||||||
|
|
||||||
|
extendToEnd()
|
||||||
|
|
||||||
|
assertEquals(QUEUE_WINDOW_ROWS, viewModel.rows.size)
|
||||||
|
assertEquals((220L..399L).toList(), viewModel.rows.map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollingOlderAfterEvictionDropsNewerRows() {
|
||||||
|
runBlocking { seedSongs(400) }
|
||||||
|
controller.playAlbum(1, "al1", 100)
|
||||||
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
extendToEnd()
|
||||||
|
|
||||||
|
viewModel.loadOlder()
|
||||||
|
awaitWindow(first = 160, last = 339)
|
||||||
|
|
||||||
|
assertEquals(QUEUE_WINDOW_ROWS, viewModel.rows.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingARowAfterEvictionKeepsPositionsContiguous() {
|
||||||
|
runBlocking { seedSongs(400) }
|
||||||
|
controller.playAlbum(1, "al1", 100)
|
||||||
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
extendToEnd()
|
||||||
|
|
||||||
|
val movedSongId =
|
||||||
|
viewModel.rows[10]
|
||||||
|
.song.song.id
|
||||||
|
val from = viewModel.rows[10].position
|
||||||
|
val to = viewModel.rows[13].position
|
||||||
|
viewModel.reorder(10, 13)
|
||||||
|
viewModel.move(from, to)
|
||||||
|
await {
|
||||||
|
viewModel.rows.size == QUEUE_WINDOW_ROWS &&
|
||||||
|
viewModel.rows.getOrNull(13)?.position == 233L
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
movedSongId,
|
||||||
|
viewModel.rows[13]
|
||||||
|
.song.song.id,
|
||||||
|
)
|
||||||
|
assertEquals(QUEUE_WINDOW_ROWS, viewModel.rows.size)
|
||||||
|
assertContiguous()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeAndUndoKeepTheBoundedWindowConsistent() {
|
||||||
|
runBlocking { seedSongs(400) }
|
||||||
|
controller.playAlbum(1, "al1", 100)
|
||||||
|
await { controller.state.value.position == 100L }
|
||||||
|
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val removedSongId =
|
||||||
|
viewModel.rows[30]
|
||||||
|
.song.song.id
|
||||||
|
|
||||||
|
viewModel.remove(viewModel.rows[30].position)
|
||||||
|
await {
|
||||||
|
viewModel.rows.size == 60 &&
|
||||||
|
viewModel.rows.firstOrNull()?.position == 70L &&
|
||||||
|
viewModel.rows
|
||||||
|
.getOrNull(30)
|
||||||
|
?.song
|
||||||
|
?.song
|
||||||
|
?.id != removedSongId
|
||||||
|
}
|
||||||
|
assertEquals((70L..129L).toList(), viewModel.rows.map { it.position })
|
||||||
|
|
||||||
|
viewModel.undo()
|
||||||
|
await {
|
||||||
|
viewModel.rows.size == 60 &&
|
||||||
|
viewModel.rows.firstOrNull()?.position == 70L &&
|
||||||
|
viewModel.rows
|
||||||
|
.getOrNull(30)
|
||||||
|
?.song
|
||||||
|
?.song
|
||||||
|
?.id == removedSongId
|
||||||
|
}
|
||||||
|
assertContiguous()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertContiguous() = assertTrue(isContiguous())
|
||||||
|
|
||||||
|
private fun isContiguous(): Boolean {
|
||||||
|
val positions = viewModel.rows.map { it.position }
|
||||||
|
return positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitWindow(
|
||||||
|
first: Long,
|
||||||
|
last: Long,
|
||||||
|
) = await {
|
||||||
|
viewModel.rows.size == (last - first + 1).toInt() &&
|
||||||
|
viewModel.rows.firstOrNull()?.position == first &&
|
||||||
|
viewModel.rows.lastOrNull()?.position == last
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extendTo(
|
||||||
|
position: Long,
|
||||||
|
first: Long,
|
||||||
|
) {
|
||||||
|
viewModel.loadNewer()
|
||||||
|
awaitWindow(first = first, last = position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extendToEnd() {
|
||||||
|
extendTo(189, first = 70)
|
||||||
|
extendTo(249, first = 70)
|
||||||
|
extendTo(309, first = 130)
|
||||||
|
extendTo(369, first = 190)
|
||||||
|
extendTo(399, first = 220)
|
||||||
|
}
|
||||||
|
|
||||||
private fun row(position: Int) =
|
private fun row(position: Int) =
|
||||||
QueueRow(
|
QueueRow(
|
||||||
position.toLong(),
|
position.toLong(),
|
||||||
@@ -189,8 +321,10 @@ class QueueViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun await(predicate: () -> Boolean) {
|
private fun await(predicate: () -> Boolean) {
|
||||||
val deadline = System.nanoTime() + 5_000_000_000L
|
repeat(500) {
|
||||||
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
|
if (predicate()) return
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
assertTrue("Timed out waiting for the expected state", predicate())
|
assertTrue("Timed out waiting for the expected state", predicate())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user