diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bdba4610..e083a337 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt deleted file mode 100644 index 0a7eb8f9..00000000 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt +++ /dev/null @@ -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() { - override fun getRefreshKey(state: PagingState): 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): LoadResult { - 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, - ) - } -} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 4c209a73..72277af8 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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 = QueuePagingSource(this, initialPosition) - suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0 suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position)) diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index 14f794bf..2f7840eb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -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() - val items: Flow> = - 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, + rows: List, + 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(null) } - var fingerY by remember { mutableFloatStateOf(0f) } - var target by remember { mutableStateOf(null) } - var settle by remember { mutableStateOf(null) } - var settleProgress by remember { mutableFloatStateOf(1f) } - var settleReady by remember { mutableStateOf(false) } - var centered by remember { mutableStateOf(false) } + var dragFrom by remember { mutableStateOf(null) } + var dragTo by remember { mutableStateOf(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, - 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, - 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, -): 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 diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt deleted file mode 100644 index 73a4eb3b..00000000 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt +++ /dev/null @@ -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() - 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(), 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, - key: Long?, - ): PagingSource.LoadResult.Page { - val params = PagingSource.LoadParams.Refresh(key, QUEUE_PAGE_SIZE, false) - return source.load(params) as PagingSource.LoadResult.Page - } - - 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, - ) - }, - ) - } -} diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 60242a5d..630736b5 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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(), ) diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt new file mode 100644 index 00000000..26171743 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -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() + 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()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 09ad3772..1b9ce587 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e805a541..68e9845b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }