mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.sqlite.wrapper)
|
||||||
implementation(libs.androidx.room3.paging)
|
implementation(libs.androidx.room3.paging)
|
||||||
implementation(libs.androidx.paging.compose)
|
implementation(libs.androidx.paging.compose)
|
||||||
|
implementation(libs.reorderable)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
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
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.room3.deferredTransaction
|
import androidx.room3.deferredTransaction
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useReaderConnection
|
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.QueueKind
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
|
|
||||||
|
const val QUEUE_CHUNK = 60
|
||||||
|
|
||||||
data class ResolvedQueueEntry(
|
data class ResolvedQueueEntry(
|
||||||
val entry: QueueEntry,
|
val entry: QueueEntry,
|
||||||
val length: Long,
|
val length: Long,
|
||||||
@@ -126,8 +127,6 @@ class QueueRepository(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pagingSource(initialPosition: Long): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this, initialPosition)
|
|
||||||
|
|
||||||
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||||
|
|
||||||
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
package com.subtracks.ui.playback
|
package com.subtracks.ui.playback
|
||||||
|
|
||||||
import androidx.compose.animation.core.animate
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -44,25 +39,12 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
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.model.CoverArtRef
|
||||||
import com.subtracks.data.repo.QUEUE_PAGE_SIZE
|
import com.subtracks.data.repo.QUEUE_CHUNK
|
||||||
import com.subtracks.data.repo.QueuePagingSource
|
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.data.repo.SourceRepository
|
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.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.library.SongRow
|
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.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
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(
|
class QueueViewModel(
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var source: QueuePagingSource? = null
|
val rows = mutableStateListOf<QueueWindowItem>()
|
||||||
|
|
||||||
val items: Flow<PagingData<QueueWindowItem>> =
|
var ready by mutableStateOf(false)
|
||||||
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
|
private set
|
||||||
QueuePagingSource(queueRepository, playbackController.state.value.position ?: 0L).also { source = it }
|
|
||||||
}.flow
|
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) {
|
fun play(position: Long) {
|
||||||
viewModelScope.launch { playbackController.playAt(position) }
|
viewModelScope.launch { playbackController.playAt(position) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
playbackController.removeAt(position)
|
|
||||||
source?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
@@ -108,16 +151,41 @@ class QueueViewModel(
|
|||||||
if (from == to) return
|
if (from == to) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.move(from, to)
|
playbackController.move(from, to)
|
||||||
source?.invalidate()
|
mutex.withLock { refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(position: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackController.removeAt(position)
|
||||||
|
mutex.withLock { refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun undo() {
|
fun undo() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.undo()
|
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
|
@Composable
|
||||||
@@ -129,116 +197,66 @@ fun QueueRoute(
|
|||||||
sourceRepository: SourceRepository = koinInject(),
|
sourceRepository: SourceRepository = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playback by controller.state.collectAsStateWithLifecycle()
|
val playback by controller.state.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(Unit) { viewModel.open() }
|
||||||
QueueScreen(
|
QueueScreen(
|
||||||
items = viewModel.items.collectAsLazyPagingItems(),
|
rows = viewModel.rows,
|
||||||
|
ready = viewModel.ready,
|
||||||
currentPosition = playback.position,
|
currentPosition = playback.position,
|
||||||
coverArt = sourceRepository::coverArt,
|
coverArt = sourceRepository::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onPlay = viewModel::play,
|
onPlay = viewModel::play,
|
||||||
onRemove = viewModel::remove,
|
onRemove = viewModel::remove,
|
||||||
|
onReorder = viewModel::reorder,
|
||||||
onMove = viewModel::move,
|
onMove = viewModel::move,
|
||||||
|
onLoadOlder = viewModel::loadOlder,
|
||||||
|
onLoadNewer = viewModel::loadNewer,
|
||||||
onUndo = viewModel::undo,
|
onUndo = viewModel::undo,
|
||||||
modifier = modifier,
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun QueueScreen(
|
fun QueueScreen(
|
||||||
items: LazyPagingItems<QueueWindowItem>,
|
rows: List<QueueWindowItem>,
|
||||||
|
ready: Boolean,
|
||||||
currentPosition: Long?,
|
currentPosition: Long?,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Unit,
|
||||||
|
onLoadOlder: () -> Unit,
|
||||||
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
||||||
var rowHeight by remember { mutableFloatStateOf(0f) }
|
var dragTo by remember { mutableStateOf<Long?>(null) }
|
||||||
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) }
|
|
||||||
|
|
||||||
val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null
|
val reorderState =
|
||||||
LaunchedEffect(currentPosition, items.itemCount, firstPosition) {
|
rememberReorderableLazyListState(listState) { from, to ->
|
||||||
val position = currentPosition ?: return@LaunchedEffect
|
onReorder(from.index, to.index)
|
||||||
val first = firstPosition ?: return@LaunchedEffect
|
dragFrom = from.key as? Long
|
||||||
val index = position - first
|
dragTo = to.key as? Long
|
||||||
if (!centered && index >= 0 && index < items.itemCount) {
|
|
||||||
listState.scrollToItem(index.toInt())
|
|
||||||
centered = true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(dragging) {
|
LaunchedEffect(listState) {
|
||||||
if (dragging == null) return@LaunchedEffect
|
snapshotFlow {
|
||||||
val threshold = with(density) { 72.dp.toPx() }
|
val info = listState.layoutInfo
|
||||||
val step = with(density) { 12.dp.toPx() }
|
val firstVisible = info.visibleItemsInfo.firstOrNull()?.index ?: -1
|
||||||
while (true) {
|
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
val from = dragging ?: break
|
firstVisible to lastVisible
|
||||||
val start = listState.layoutInfo.viewportStartOffset.toFloat()
|
}.collect { (firstVisible, lastVisible) ->
|
||||||
val end = listState.layoutInfo.viewportEndOffset.toFloat()
|
if (firstVisible in 0..LOAD_THRESHOLD) onLoadOlder()
|
||||||
val edge =
|
if (lastVisible >= 0 && lastVisible >= rows.size - 1 - LOAD_THRESHOLD) onLoadNewer()
|
||||||
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(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 ->
|
val showUndo: (String) -> Unit = { message ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
@@ -262,11 +280,11 @@ fun QueueScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
!ready -> {
|
||||||
LoadingState(Modifier.padding(padding))
|
LoadingState(Modifier.padding(padding))
|
||||||
}
|
}
|
||||||
|
|
||||||
items.itemCount == 0 -> {
|
rows.isEmpty() -> {
|
||||||
EmptyState("The queue is empty.", Modifier.padding(padding))
|
EmptyState("The queue is empty.", Modifier.padding(padding))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,68 +294,43 @@ fun QueueScreen(
|
|||||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
) {
|
) {
|
||||||
items(count = items.itemCount, key = items.itemKey { it.position }) { index ->
|
items(count = rows.size, key = { rows[it].position }) { index ->
|
||||||
val item = items[index]
|
val row = rows[index]
|
||||||
if (item != null) {
|
ReorderableItem(
|
||||||
val active = settle
|
state = reorderState,
|
||||||
val draggedPosition =
|
key = row.position,
|
||||||
when {
|
animateItemModifier =
|
||||||
active == null -> dragging
|
Modifier.animateItem(
|
||||||
!settleReady -> active.from
|
fadeInSpec = null,
|
||||||
else -> active.to
|
fadeOutSpec = null,
|
||||||
}
|
placementSpec = tween(200),
|
||||||
|
),
|
||||||
|
) { isDragging ->
|
||||||
QueueRow(
|
QueueRow(
|
||||||
item = item,
|
item = row,
|
||||||
isPlaying = item.position == currentPosition,
|
isPlaying = row.position == currentPosition,
|
||||||
|
floating = isDragging,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
floating = draggedPosition == item.position,
|
dragHandle =
|
||||||
translation =
|
Modifier.draggableHandle(
|
||||||
translationFor(
|
onDragStarted = {
|
||||||
item.position,
|
dragFrom = row.position
|
||||||
active,
|
dragTo = row.position
|
||||||
dragging,
|
},
|
||||||
fingerY,
|
onDragStopped = {
|
||||||
settleReady,
|
val from = dragFrom
|
||||||
settleProgress,
|
val to = dragTo
|
||||||
rowHeight,
|
if (from != null && to != null && from != to) {
|
||||||
listState,
|
onMove(from, to)
|
||||||
items,
|
showUndo("Queue reordered")
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
indicator = indicatorFor(item.position, dragging, target),
|
onClick = { onPlay(row.position) },
|
||||||
onClick = { onPlay(item.position) },
|
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onRemove(item.position)
|
onRemove(row.position)
|
||||||
showUndo("Removed from queue")
|
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
|
@Composable
|
||||||
private fun QueueRow(
|
private fun QueueRow(
|
||||||
item: QueueWindowItem,
|
item: QueueWindowItem,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
|
||||||
floating: Boolean,
|
floating: Boolean,
|
||||||
translation: Float,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
indicator: DropIndicator?,
|
dragHandle: Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onRemove: () -> 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 =
|
||||||
Modifier
|
Modifier
|
||||||
.zIndex(if (floating) 1f else 0f)
|
.background(
|
||||||
.graphicsLayer { translationY = translation },
|
if (floating) {
|
||||||
) {
|
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f)
|
||||||
SongRow(
|
} else {
|
||||||
song = item.item.song,
|
Color.Transparent
|
||||||
coverArtId = item.item.coverArt,
|
},
|
||||||
coverArt = coverArt,
|
).clickable(onClick = onClick),
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
QueueScreen(
|
QueueScreen(
|
||||||
items =
|
rows =
|
||||||
remember {
|
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
|
||||||
flowOf(
|
ready = true,
|
||||||
PagingData.from(
|
|
||||||
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}.collectAsLazyPagingItems(),
|
|
||||||
currentPosition = 1,
|
currentPosition = 1,
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = {},
|
onRemove = {},
|
||||||
|
onReorder = { _, _ -> },
|
||||||
onMove = { _, _ -> },
|
onMove = { _, _ -> },
|
||||||
|
onLoadOlder = {},
|
||||||
|
onLoadNewer = {},
|
||||||
onUndo = {},
|
onUndo = {},
|
||||||
modifier = Modifier.fillMaxSize(),
|
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.
|
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
|
## Data model
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ room3 = "3.0.3"
|
|||||||
sqliteBundled = "2.7.1"
|
sqliteBundled = "2.7.1"
|
||||||
paging = "3.5.1"
|
paging = "3.5.1"
|
||||||
navigationCompose = "2.10.1"
|
navigationCompose = "2.10.1"
|
||||||
|
reorderable = "3.1.0"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
coil = "3.6.3"
|
coil = "3.6.3"
|
||||||
koin = "4.2.2"
|
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-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-room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" }
|
||||||
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
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-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-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||||
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||||
|
|||||||
Reference in New Issue
Block a user