reorder the queue with the reorderable library

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 68ca600bb5
commit a682cfc1cb
9 changed files with 337 additions and 535 deletions
+1
View File
@@ -112,6 +112,7 @@ dependencies {
implementation(libs.androidx.room3.sqlite.wrapper)
implementation(libs.androidx.room3.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.reorderable)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session)
@@ -1,36 +0,0 @@
package com.subtracks.data.repo
import androidx.paging.PagingSource
import androidx.paging.PagingState
const val QUEUE_PAGE_SIZE = 60
class QueuePagingSource(
private val repository: QueueRepository,
private val initialPosition: Long,
) : PagingSource<Long, QueueWindowItem>() {
override fun getRefreshKey(state: PagingState<Long, QueueWindowItem>): Long? {
val anchor = state.anchorPosition ?: return null
val first =
state.pages
.firstOrNull()
?.data
?.firstOrNull()
?.position ?: return null
return (first + anchor) / QUEUE_PAGE_SIZE
}
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> {
val snapshot = repository.snapshot()
if (snapshot.size == 0L) return LoadResult.Page(emptyList(), null, null)
val lastPage = (snapshot.size - 1) / QUEUE_PAGE_SIZE
val page = (params.key ?: (initialPosition.coerceAtLeast(0) / QUEUE_PAGE_SIZE)).coerceIn(0, lastPage)
val offset = page * QUEUE_PAGE_SIZE
val end = (offset + params.loadSize).coerceAtMost(snapshot.size)
return LoadResult.Page(
data = repository.range(snapshot, offset, end - 1),
prevKey = if (page > 0) page - 1 else null,
nextKey = if (end < snapshot.size) end / QUEUE_PAGE_SIZE else null,
)
}
}
@@ -1,6 +1,5 @@
package com.subtracks.data.repo
import androidx.paging.PagingSource
import androidx.room3.deferredTransaction
import androidx.room3.immediateTransaction
import androidx.room3.useReaderConnection
@@ -11,6 +10,8 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem
const val QUEUE_CHUNK = 60
data class ResolvedQueueEntry(
val entry: QueueEntry,
val length: Long,
@@ -126,8 +127,6 @@ class QueueRepository(
return true
}
fun pagingSource(initialPosition: Long): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this, initialPosition)
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
@@ -1,21 +1,16 @@
package com.subtracks.ui.playback
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -35,7 +30,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -44,25 +39,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.QUEUE_PAGE_SIZE
import com.subtracks.data.repo.QueuePagingSource
import com.subtracks.data.repo.QUEUE_CHUNK
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.data.repo.SourceRepository
@@ -70,37 +52,98 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.floor
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
private const val LOAD_THRESHOLD = 5
class QueueViewModel(
private val queueRepository: QueueRepository,
private val playbackController: PlaybackController,
) : ViewModel() {
private var source: QueuePagingSource? = null
val rows = mutableStateListOf<QueueWindowItem>()
val items: Flow<PagingData<QueueWindowItem>> =
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
QueuePagingSource(queueRepository, playbackController.state.value.position ?: 0L).also { source = it }
}.flow
var ready by mutableStateOf(false)
private set
private val mutex = Mutex()
private var size = 0L
private var first = 0L
private var last = -1L
fun open() {
viewModelScope.launch {
mutex.withLock {
val snapshot = queueRepository.snapshot()
size = snapshot.size
rows.clear()
first = 0
last = -1
if (size > 0L) {
val cursor = (playbackController.state.value.position ?: 0L).coerceIn(0, size - 1)
val start = (cursor - QUEUE_CHUNK / 2).coerceAtLeast(0)
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, start, end)
rows.addAll(loaded)
first = loaded.firstOrNull()?.position ?: 0
last = loaded.lastOrNull()?.position ?: -1
}
ready = true
}
}
}
fun loadOlder() {
if (first <= 0L) return
viewModelScope.launch {
mutex.withLock {
if (first <= 0L || rows.isEmpty()) return@withLock
val snapshot = queueRepository.snapshot()
size = snapshot.size
val from = (first - QUEUE_CHUNK).coerceAtLeast(0)
val loaded = queueRepository.range(snapshot, from, first - 1)
if (loaded.isEmpty()) return@withLock
rows.addAll(0, loaded)
first = loaded.first().position
}
}
}
fun loadNewer() {
if (rows.isEmpty() || last >= size - 1) return
viewModelScope.launch {
mutex.withLock {
if (rows.isEmpty() || last >= size - 1) return@withLock
val snapshot = queueRepository.snapshot()
size = snapshot.size
if (last >= size - 1) return@withLock
val to = (last + QUEUE_CHUNK).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, last + 1, to)
if (loaded.isEmpty()) return@withLock
rows.addAll(loaded)
last = loaded.last().position
}
}
}
fun reorder(
fromIndex: Int,
toIndex: Int,
) {
if (fromIndex == toIndex) return
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
rows.add(toIndex, rows.removeAt(fromIndex))
}
fun play(position: Long) {
viewModelScope.launch { playbackController.playAt(position) }
}
fun remove(position: Long) {
viewModelScope.launch {
playbackController.removeAt(position)
source?.invalidate()
}
}
fun move(
from: Long,
to: Long,
@@ -108,16 +151,41 @@ class QueueViewModel(
if (from == to) return
viewModelScope.launch {
playbackController.move(from, to)
source?.invalidate()
mutex.withLock { refresh() }
}
}
fun remove(position: Long) {
viewModelScope.launch {
playbackController.removeAt(position)
mutex.withLock { refresh() }
}
}
fun undo() {
viewModelScope.launch {
playbackController.undo()
source?.invalidate()
mutex.withLock { refresh() }
}
}
private suspend fun refresh() {
val snapshot = queueRepository.snapshot()
size = snapshot.size
if (size == 0L) {
rows.clear()
first = 0
last = -1
return
}
val start = first.coerceIn(0, size - 1)
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, start, end)
rows.clear()
rows.addAll(loaded)
first = loaded.firstOrNull()?.position ?: start
last = loaded.lastOrNull()?.position ?: -1
}
}
@Composable
@@ -129,116 +197,66 @@ fun QueueRoute(
sourceRepository: SourceRepository = koinInject(),
) {
val playback by controller.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.open() }
QueueScreen(
items = viewModel.items.collectAsLazyPagingItems(),
rows = viewModel.rows,
ready = viewModel.ready,
currentPosition = playback.position,
coverArt = sourceRepository::coverArt,
onBack = onBack,
onPlay = viewModel::play,
onRemove = viewModel::remove,
onReorder = viewModel::reorder,
onMove = viewModel::move,
onLoadOlder = viewModel::loadOlder,
onLoadNewer = viewModel::loadNewer,
onUndo = viewModel::undo,
modifier = modifier,
)
}
private enum class DropIndicator { Above, Below }
private data class Settle(
val from: Long,
val to: Long,
val songId: String,
val fingerY: Float,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(
items: LazyPagingItems<QueueWindowItem>,
rows: List<QueueWindowItem>,
ready: Boolean,
currentPosition: Long?,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
onPlay: (Long) -> Unit,
onRemove: (Long) -> Unit,
onReorder: (Int, Int) -> Unit,
onMove: (Long, Long) -> Unit,
onLoadOlder: () -> Unit,
onLoadNewer: () -> Unit,
onUndo: () -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var rowHeight by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf<Long?>(null) }
var fingerY by remember { mutableFloatStateOf(0f) }
var target by remember { mutableStateOf<Long?>(null) }
var settle by remember { mutableStateOf<Settle?>(null) }
var settleProgress by remember { mutableFloatStateOf(1f) }
var settleReady by remember { mutableStateOf(false) }
var centered by remember { mutableStateOf(false) }
var dragFrom by remember { mutableStateOf<Long?>(null) }
var dragTo by remember { mutableStateOf<Long?>(null) }
val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null
LaunchedEffect(currentPosition, items.itemCount, firstPosition) {
val position = currentPosition ?: return@LaunchedEffect
val first = firstPosition ?: return@LaunchedEffect
val index = position - first
if (!centered && index >= 0 && index < items.itemCount) {
listState.scrollToItem(index.toInt())
centered = true
val reorderState =
rememberReorderableLazyListState(listState) { from, to ->
onReorder(from.index, to.index)
dragFrom = from.key as? Long
dragTo = to.key as? Long
}
}
LaunchedEffect(dragging) {
if (dragging == null) return@LaunchedEffect
val threshold = with(density) { 72.dp.toPx() }
val step = with(density) { 12.dp.toPx() }
while (true) {
val from = dragging ?: break
val start = listState.layoutInfo.viewportStartOffset.toFloat()
val end = listState.layoutInfo.viewportEndOffset.toFloat()
val edge =
when {
fingerY < start + threshold -> -step
fingerY > end - threshold -> step
else -> 0f
}
if (edge != 0f) {
val next = slotOffset(listState, items, rowHeight, from)?.minus(edge)
if (next != null && next + rowHeight > start + 1f && next < end - 1f) {
listState.scrollBy(edge)
}
}
delay(16)
LaunchedEffect(listState) {
snapshotFlow {
val info = listState.layoutInfo
val firstVisible = info.visibleItemsInfo.firstOrNull()?.index ?: -1
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
firstVisible to lastVisible
}.collect { (firstVisible, lastVisible) ->
if (firstVisible in 0..LOAD_THRESHOLD) onLoadOlder()
if (lastVisible >= 0 && lastVisible >= rows.size - 1 - LOAD_THRESHOLD) onLoadNewer()
}
}
LaunchedEffect(settle) {
val current = settle ?: return@LaunchedEffect
val first = if (items.itemCount > 0) items.peek(0)?.position else null
if (first != null) {
val index = (current.to - first).toInt()
withTimeoutOrNull(SETTLE_TIMEOUT_MS) {
snapshotFlow {
if (index in 0 until items.itemCount) {
items
.peek(index)
?.item
?.song
?.id
} else {
null
}
}.first { it == current.songId }
}
}
settleReady = true
settleProgress = 0f
animate(0f, 1f, animationSpec = tween(SETTLE_DURATION_MS)) { value, _ -> settleProgress = value }
settle = null
settleReady = false
settleProgress = 1f
}
val showUndo: (String) -> Unit = { message ->
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
@@ -262,11 +280,11 @@ fun QueueScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
!ready -> {
LoadingState(Modifier.padding(padding))
}
items.itemCount == 0 -> {
rows.isEmpty() -> {
EmptyState("The queue is empty.", Modifier.padding(padding))
}
@@ -276,68 +294,43 @@ fun QueueScreen(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(count = items.itemCount, key = items.itemKey { it.position }) { index ->
val item = items[index]
if (item != null) {
val active = settle
val draggedPosition =
when {
active == null -> dragging
!settleReady -> active.from
else -> active.to
}
items(count = rows.size, key = { rows[it].position }) { index ->
val row = rows[index]
ReorderableItem(
state = reorderState,
key = row.position,
animateItemModifier =
Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null,
placementSpec = tween(200),
),
) { isDragging ->
QueueRow(
item = item,
isPlaying = item.position == currentPosition,
item = row,
isPlaying = row.position == currentPosition,
floating = isDragging,
coverArt = coverArt,
floating = draggedPosition == item.position,
translation =
translationFor(
item.position,
active,
dragging,
fingerY,
settleReady,
settleProgress,
rowHeight,
listState,
items,
dragHandle =
Modifier.draggableHandle(
onDragStarted = {
dragFrom = row.position
dragTo = row.position
},
onDragStopped = {
val from = dragFrom
val to = dragTo
if (from != null && to != null && from != to) {
onMove(from, to)
showUndo("Queue reordered")
}
},
),
indicator = indicatorFor(item.position, dragging, target),
onClick = { onPlay(item.position) },
onClick = { onPlay(row.position) },
onRemove = {
onRemove(item.position)
onRemove(row.position)
showUndo("Removed from queue")
},
onSize = { rowHeight = it.toFloat() },
onDragStart = {
if (settle == null) {
dragging = item.position
target = item.position
fingerY =
(slotOffset(listState, items, rowHeight, item.position) ?: 0f) +
rowHeight / 2f
}
},
onDrag = { amount ->
fingerY += amount
target = pointerTarget(listState, items, rowHeight, fingerY, item.position)
},
onDragEnd = {
val from = dragging
val to = target
if (from != null && to != null && to != from) {
settle = Settle(from, to, item.item.song.id, fingerY)
onMove(from, to)
showUndo("Queue reordered")
}
dragging = null
target = null
},
onDragCancel = {
dragging = null
target = null
},
)
}
}
@@ -347,168 +340,43 @@ fun QueueScreen(
}
}
private fun slotOffset(
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>,
rowHeight: Float,
position: Long,
): Float? {
val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return null
val firstPosition = items.peek(first.index)?.position ?: return null
return first.offset + (position - firstPosition) * rowHeight
}
private fun pointerTarget(
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>,
rowHeight: Float,
fingerY: Float,
from: Long,
): Long {
if (items.itemCount == 0 || rowHeight <= 0f) return from
val first = listState.layoutInfo.visibleItemsInfo.firstOrNull() ?: return from
val firstPosition = items.peek(first.index)?.position ?: return from
val index = firstPosition + floor((fingerY - first.offset) / rowHeight).toLong()
val firstLoaded = items.peek(0)?.position ?: firstPosition
val lastLoaded = items.peek(items.itemCount - 1)?.position ?: firstPosition
return index.coerceIn(minOf(firstLoaded, lastLoaded), maxOf(firstLoaded, lastLoaded))
}
private fun translationFor(
position: Long,
settle: Settle?,
dragging: Long?,
fingerY: Float,
settleReady: Boolean,
progress: Float,
rowHeight: Float,
listState: LazyListState,
items: LazyPagingItems<QueueWindowItem>,
): Float =
when {
settle == null -> {
if (dragging == position) fingerY - (slotOffset(listState, items, rowHeight, position) ?: fingerY) else 0f
}
!settleReady -> {
if (position == settle.from) settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY) else 0f
}
position == settle.to -> {
(settle.fingerY - (slotOffset(listState, items, rowHeight, position) ?: settle.fingerY)) * (1f - progress)
}
settle.to > settle.from && position in settle.from until settle.to -> {
rowHeight * (1f - progress)
}
settle.to < settle.from && position in (settle.to + 1)..settle.from -> {
-rowHeight * (1f - progress)
}
else -> {
0f
}
}
private fun indicatorFor(
position: Long,
dragging: Long?,
target: Long?,
): DropIndicator? =
when {
dragging == null || target == null || position != target -> null
target > dragging -> DropIndicator.Below
target < dragging -> DropIndicator.Above
else -> null
}
@Composable
private fun QueueRow(
item: QueueWindowItem,
isPlaying: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?,
floating: Boolean,
translation: Float,
indicator: DropIndicator?,
coverArt: (String?, Boolean) -> CoverArtRef?,
dragHandle: Modifier,
onClick: () -> Unit,
onRemove: () -> Unit,
onSize: (Int) -> Unit,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
) {
Box(
SongRow(
song = item.item.song,
coverArtId = item.item.coverArt,
coverArt = coverArt,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
modifier = Modifier.size(20.dp),
)
}
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
},
modifier =
Modifier
.zIndex(if (floating) 1f else 0f)
.graphicsLayer { translationY = translation },
) {
SongRow(
song = item.item.song,
coverArtId = item.item.coverArt,
coverArt = coverArt,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
modifier = Modifier.size(20.dp),
)
}
Box(
modifier =
Modifier
.size(40.dp)
.pointerInput(item.position) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, amount ->
change.consume()
onDrag(amount.y)
},
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
)
},
contentAlignment = Alignment.Center,
) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
},
modifier =
Modifier
.background(
if (floating) {
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
} else {
Color.Transparent
},
).onSizeChanged { size -> onSize(size.height) }
.clickable(onClick = onClick),
)
when (indicator) {
DropIndicator.Above -> DropIndicatorLine(Modifier.align(Alignment.TopStart))
DropIndicator.Below -> DropIndicatorLine(Modifier.align(Alignment.BottomStart))
null -> Unit
}
}
}
@Composable
private fun DropIndicatorLine(modifier: Modifier = Modifier) {
Box(
modifier =
modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.primary),
.background(
if (floating) {
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
} else {
Color.Transparent
},
).clickable(onClick = onClick),
)
}
private const val SETTLE_DURATION_MS = 200
private const val SETTLE_TIMEOUT_MS = 1_000L
@@ -1,159 +0,0 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
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.SongListItem
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(initialPosition = 0)
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 theFirstPageStartsAtTheInitialPosition() =
runTest {
seedSongs(130)
repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource(initialPosition = 100)
val first = load(source, key = null)
assertEquals((60L until 120L).toList(), first.data.map { it.position })
assertEquals(2L, first.nextKey)
assertEquals(0L, first.prevKey)
}
@Test
fun theRefreshKeyUsesTheLoadedPositions() =
runTest {
val source = repository.pagingSource(initialPosition = 100)
val state =
PagingState(
pages = listOf(PagingSource.LoadResult.Page(listOf(itemAt(60)), prevKey = 0L, nextKey = 2L)),
anchorPosition = 5,
config = PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false),
leadingPlaceholderCount = 0,
)
assertEquals(1L, source.getRefreshKey(state))
}
@Test
fun anEmptyQueueLoadsNothing() =
runTest {
val result = load(repository.pagingSource(0), key = null)
assertEquals(emptyList<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey)
assertEquals(null, result.prevKey)
}
private fun itemAt(position: Long) =
QueueWindowItem(
position,
SongListItem(
song =
Song(
sourceId = 1,
id = "s$position",
albumId = "al1",
artistId = "ar1",
title = "Song $position",
album = "Album",
artist = "Artist",
duration = 100,
track = position,
disc = 1,
starred = null,
genre = null,
),
coverArt = null,
),
)
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,
)
},
)
}
}
@@ -223,20 +223,18 @@ class AppScreenshotTest {
composeRule.setContent {
SubtracksTheme {
QueueScreen(
items =
remember {
flowOf(
PagingData.from(
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
),
)
}.collectAsLazyPagingItems(),
rows =
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
ready = true,
currentPosition = 1,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onPlay = {},
onRemove = {},
onReorder = { _, _ -> },
onMove = { _, _ -> },
onLoadOlder = {},
onLoadNewer = {},
onUndo = {},
modifier = Modifier.fillMaxSize(),
)
@@ -0,0 +1,129 @@
package com.subtracks.ui.playback
import android.content.Context
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 com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class QueueViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var queues: QueueRepository
private lateinit var controller: PlaybackController
private lateinit var viewModel: QueueViewModel
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db)
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher)
viewModel = QueueViewModel(queues, controller)
}
@After
fun tearDown() {
controller.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@Test
fun openingLoadsAWindowAroundTheCursorAndExtendingGrowsIt() {
runBlocking { seedSongs(200) }
controller.playSongs(1, 100)
await { controller.state.value.position == 100L }
viewModel.open()
await { viewModel.ready }
assertEquals((70L..129L).toList(), viewModel.rows.map { it.position })
viewModel.loadNewer()
await { viewModel.rows.lastOrNull()?.position == 189L }
assertEquals((70L..189L).toList(), viewModel.rows.map { it.position })
viewModel.loadOlder()
await { viewModel.rows.firstOrNull()?.position == 10L }
assertEquals((10L..189L).toList(), viewModel.rows.map { it.position })
}
@Test
fun reorderingMovesTheRowLocally() {
runBlocking { seedSongs(200) }
controller.playSongs(1, 100)
await { controller.state.value.position == 100L }
viewModel.open()
await { viewModel.ready }
viewModel.reorder(fromIndex = 0, toIndex = 2)
val positions = viewModel.rows.map { it.position }
assertEquals(listOf(71L, 72L, 70L), positions.take(3))
}
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,
)
},
)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected state", predicate())
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ Because the resolver reports *which entry* a position falls in, `PlaybackState`
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it pages with queries bounded to one page per entry (never the whole queue) and rewrites `queue_entries` to edit. Removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.
## Data model
+2
View File
@@ -11,6 +11,7 @@ room3 = "3.0.3"
sqliteBundled = "2.7.1"
paging = "3.5.1"
navigationCompose = "2.10.1"
reorderable = "3.1.0"
datastore = "1.2.1"
coil = "3.6.3"
koin = "4.2.2"
@@ -48,6 +49,7 @@ androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", v
androidx-room3-sqlite-wrapper = { group = "androidx.room3", name = "room3-sqlite-wrapper", version.ref = "room3" }
androidx-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }