mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
reorder the queue with the reorderable library
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user