From 7fc142ce6f95553bd0f17be07fce2ff5db7adc46 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:54:33 +0000 Subject: [PATCH] add a one-step undo and harden queue edits --- .../kotlin/com/subtracks/data/db/QueueDao.kt | 2 +- .../subtracks/data/repo/QueuePagingSource.kt | 3 +- .../subtracks/data/repo/QueueRepository.kt | 51 ++++--- .../subtracks/playback/PlaybackController.kt | 136 ++++++++++++------ .../com/subtracks/ui/playback/QueueScreen.kt | 62 ++++++-- .../data/repo/QueuePagingSourceTest.kt | 18 ++- .../data/repo/QueueRepositoryTest.kt | 13 ++ .../playback/PlaybackControllerTest.kt | 75 +++++++++- .../com/subtracks/ui/AppScreenshotTest.kt | 1 + docs/architecture.md | 2 +- docs/implementation-plan.md | 2 +- 11 files changed, 283 insertions(+), 82 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 7510b285..3c5a986d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -12,7 +12,7 @@ private const val ALBUM_SONGS_SQL = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " + - "ORDER BY songs.disc, songs.track" + "ORDER BY songs.disc, songs.track, songs.id" private const val SONG_SQL = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt index bb07f29d..45387c43 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt @@ -7,13 +7,14 @@ const val QUEUE_PAGE_SIZE = 60 class QueuePagingSource( private val repository: QueueRepository, + private val initialPosition: Long, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Long? = state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() } override suspend fun load(params: LoadParams): LoadResult { val snapshot = repository.snapshot() - val page = params.key ?: 0L + val page = params.key ?: (initialPosition.coerceAtLeast(0) / QUEUE_PAGE_SIZE) val offset = page * QUEUE_PAGE_SIZE val previousKey = if (page > 0) page - 1 else null if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, 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 ee3ec20a..4c209a73 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -1,7 +1,9 @@ package com.subtracks.data.repo import androidx.paging.PagingSource +import androidx.room3.deferredTransaction import androidx.room3.immediateTransaction +import androidx.room3.useReaderConnection import androidx.room3.useWriterConnection import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.PlaybackCursor @@ -61,7 +63,12 @@ class QueueRepository( dao.setCursor(PlaybackCursor(queuePosition = 0)) } - suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }) + suspend fun snapshot(): QueueSnapshot = + db.useReaderConnection { transactor -> + transactor.deferredTransaction { + QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }) + } + } suspend fun itemAt( snapshot: QueueSnapshot, @@ -104,21 +111,22 @@ class QueueRepository( suspend fun removeAt( snapshot: QueueSnapshot, position: Long, - ) = write(removeEntry(snapshot.entries, position)) + ) = write(removeEntry(snapshot.entries, position).map { it.entry }) suspend fun move( from: Long, to: Long, - ) { + ): Boolean { val before = snapshot() - val located = before.locate(from) ?: return - val song = itemAt(before, from) ?: return - write(removeEntry(before.entries, from)) - val after = snapshot() - write(insertEntry(after.entries, to.coerceIn(0, after.size), songEntry(located.first.sourceId, song.song.id))) + val located = before.locate(from) ?: return false + val song = itemAt(before, from) ?: return false + val removed = removeEntry(before.entries, from) + val moved = songEntry(located.first.sourceId, song.song.id) + write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved)) + return true } - fun pagingSource(): PagingSource = QueuePagingSource(this) + fun pagingSource(initialPosition: Long): PagingSource = QueuePagingSource(this, initialPosition) suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0 @@ -165,19 +173,23 @@ class QueueRepository( private fun removeEntry( entries: List, index: Long, - ): List { - val result = mutableListOf() + ): List { + val result = mutableListOf() var start = 0L for (resolved in entries) { val end = start + resolved.length - 1 if (index in start..end) { val offset = resolved.entry.offset + (index - start) val first = resolved.entry.offset - val last = resolved.entry.offset + resolved.length - 1 - if (offset > first) result += resolved.entry.range(first, offset - 1) - if (offset < last) result += resolved.entry.range(offset + 1, last) + val last = first + resolved.length - 1 + if (offset > first) { + result += ResolvedQueueEntry(resolved.entry.range(first, offset - 1), offset - first) + } + if (offset < last) { + result += ResolvedQueueEntry(resolved.entry.range(offset + 1, last), last - offset) + } } else { - result += resolved.entry + result += resolved } start += resolved.length } @@ -202,12 +214,11 @@ class QueueRepository( } !placed && index in start..end -> { - val offset = resolved.entry.offset + (index - start) - val first = resolved.entry.offset - val last = resolved.entry.offset + resolved.length - 1 - if (offset > first) result += resolved.entry.range(first, offset - 1) + val cut = index - start + val offset = resolved.entry.offset + if (cut > 0) result += resolved.entry.range(offset, offset + cut - 1) result += newEntry - if (offset <= last) result += resolved.entry.range(offset, last) + if (cut < resolved.length) result += resolved.entry.range(offset + cut, offset + resolved.length - 1) placed = true } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index edd2e90a..ab7f7a61 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -46,6 +46,11 @@ data class PlaybackState( val hasPrevious: Boolean = false, ) +private data class QueueUndo( + val entries: List, + val cursor: Long, +) + class PlaybackController( private val sourceRepository: SourceRepository, private val queueRepository: QueueRepository, @@ -72,6 +77,7 @@ class PlaybackController( private var windowStart = 0L private var windowEnd = -1L private var updating = false + private var lastEdit: QueueUndo? = null init { scope.launch { @@ -137,6 +143,7 @@ class PlaybackController( player.ensurePrepared() player.seekToIndex((target - windowStart).toInt()) player.play() + shiftWindowLocked(target) refresh(target) } else { loadWindow(target, autoplay = true) @@ -150,24 +157,42 @@ class PlaybackController( val snapshot = snapshot ?: return@withLock if (position !in 0 until snapshot.size) return@withLock val player = player ?: return@withLock + val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor()) if (snapshot.size == 1L) { queueRepository.removeAt(snapshot, position) stopLocked() + lastEdit = undo return@withLock } val current = currentPosition() ?: return@withLock val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2) + val playing = player.playWhenReady queueRepository.removeAt(snapshot, position) this.snapshot = queueRepository.snapshot() windowJob?.cancel() - if (position < windowStart) { - windowStart-- - windowEnd-- - } else if (position <= windowEnd) { - updating = true - player.removeAt((position - windowStart).toInt()) - updating = false - windowEnd-- + lastEdit = undo + when { + position == current -> { + loadWindow(target, autoplay = playing, startPositionMs = 0) + } + + position < windowStart -> { + windowStart-- + windowEnd-- + shiftWindowLocked(target) + } + + position <= windowEnd -> { + updating = true + player.removeAt((position - windowStart).toInt()) + updating = false + windowEnd-- + shiftWindowLocked(target) + } + + else -> { + shiftWindowLocked(target) + } } queueRepository.setCursor(target) refresh(target) @@ -182,10 +207,12 @@ class PlaybackController( if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock val player = player ?: return@withLock val current = currentPosition() ?: return@withLock - val target = movedCursor(current, from, to) - queueRepository.move(from, to) + val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor()) + if (!queueRepository.move(from, to)) return@withLock this.snapshot = queueRepository.snapshot() + val target = movedCursor(current, from, to) windowJob?.cancel() + lastEdit = undo if (from in windowStart..windowEnd && to in windowStart..windowEnd) { updating = true player.move((from - windowStart).toInt(), (to - windowStart).toInt()) @@ -193,10 +220,34 @@ class PlaybackController( } else { loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) } + shiftWindowLocked(target) queueRepository.setCursor(target) refresh(target) } + suspend fun undo() = + startLock.withLock { + val undo = lastEdit ?: return@withLock + val player = player ?: return@withLock + val playing = player.playWhenReady + val playingId = player.currentItem?.id + val positionMs = player.currentPositionMs + queueRepository.replace(undo.entries) + val restored = queueRepository.snapshot() + this.snapshot = restored + lastEdit = null + if (restored.size == 0L) { + stopLocked() + return@withLock + } + val target = undo.cursor.coerceIn(0, restored.size - 1) + windowJob?.cancel() + val same = queueRepository.itemAt(restored, target)?.song?.id == playingId + loadWindow(target, autoplay = playing, startPositionMs = if (same) positionMs else 0) + queueRepository.setCursor(target) + refresh(target) + } + fun togglePlayPause() { val player = player ?: return if (player.isEnded) { @@ -242,6 +293,7 @@ class PlaybackController( windowEnd = -1 lastDurationMs = 0 lastError = null + lastEdit = null queueRepository.replace(entries) val snapshot = queueRepository.snapshot() this.snapshot = snapshot @@ -290,6 +342,7 @@ class PlaybackController( queueSourceId = null windowStart = 0 windowEnd = -1 + lastEdit = null player?.run { stop() clear() @@ -359,39 +412,40 @@ class PlaybackController( } } - private suspend fun shiftWindow(center: Long) = - startLock.withLock { - val player = player ?: return@withLock - val snapshot = queueRepository.snapshot() - this.snapshot = snapshot - if (snapshot.size == 0L) return@withLock - val target = center.coerceIn(0, snapshot.size - 1) - val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0) - val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1) - updating = true - try { - while (windowStart > desiredStart) { - val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break - player.addFirst(item.toQueueItem()) - windowStart-- - } - while (windowStart < desiredStart) { - player.removeFirst() - windowStart++ - } - while (windowEnd < desiredEnd) { - val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break - player.addLast(item.toQueueItem()) - windowEnd++ - } - while (windowEnd > desiredEnd) { - player.removeLast() - windowEnd-- - } - } finally { - updating = false + private suspend fun shiftWindow(center: Long) = startLock.withLock { shiftWindowLocked(center) } + + private suspend fun shiftWindowLocked(center: Long) { + val player = player ?: return + val snapshot = queueRepository.snapshot() + this.snapshot = snapshot + if (snapshot.size == 0L) return + val target = center.coerceIn(0, snapshot.size - 1) + val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0) + val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1) + updating = true + try { + while (windowStart > desiredStart) { + val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break + player.addFirst(item.toQueueItem()) + windowStart-- } + while (windowStart < desiredStart) { + player.removeFirst() + windowStart++ + } + while (windowEnd < desiredEnd) { + val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break + player.addLast(item.toQueueItem()) + windowEnd++ + } + while (windowEnd > desiredEnd) { + player.removeLast() + windowEnd-- + } + } finally { + updating = false } + } private fun currentPosition(): Long? { val player = player ?: return null 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 3ddb570c..ed9969a9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -18,6 +18,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -26,6 +30,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,7 +46,6 @@ import androidx.paging.LoadState import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import androidx.paging.cachedIn import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.subtracks.data.model.CoverArtRef @@ -54,14 +58,12 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import kotlin.math.roundToInt -@OptIn(ExperimentalCoroutinesApi::class) class QueueViewModel( private val queueRepository: QueueRepository, private val playbackController: PlaybackController, @@ -70,8 +72,8 @@ class QueueViewModel( val items: Flow> = Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) { - QueuePagingSource(queueRepository).also { source = it } - }.flow.cachedIn(viewModelScope) + QueuePagingSource(queueRepository, playbackController.state.value.position ?: 0L).also { source = it } + }.flow fun play(position: Long) { viewModelScope.launch { playbackController.playAt(position) } @@ -94,6 +96,13 @@ class QueueViewModel( source?.invalidate() } } + + fun undo() { + viewModelScope.launch { + playbackController.undo() + source?.invalidate() + } + } } @Composable @@ -113,6 +122,7 @@ fun QueueRoute( onPlay = viewModel::play, onRemove = viewModel::remove, onMove = viewModel::move, + onUndo = viewModel::undo, modifier = modifier, ) } @@ -127,22 +137,36 @@ fun QueueScreen( onPlay: (Long) -> Unit, onRemove: (Long) -> Unit, onMove: (Long, Long) -> Unit, + onUndo: () -> Unit, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() var rowHeight by remember { mutableFloatStateOf(0f) } var dragging by remember { mutableStateOf(null) } var dragOffset by remember { mutableFloatStateOf(0f) } var centered by remember { mutableStateOf(false) } - LaunchedEffect(currentPosition, items.itemCount) { + val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null + LaunchedEffect(currentPosition, items.itemCount, firstPosition) { val position = currentPosition ?: return@LaunchedEffect - if (!centered && position.toInt() < items.itemCount) { - listState.scrollToItem(position.toInt()) + val first = firstPosition ?: return@LaunchedEffect + val index = position - first + if (!centered && index >= 0 && index < items.itemCount) { + listState.scrollToItem(index.toInt()) centered = true } } + val showUndo: (String) -> Unit = { message -> + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar(message, actionLabel = "Undo", duration = SnackbarDuration.Short) + if (result == SnackbarResult.ActionPerformed) onUndo() + } + } + Scaffold( modifier = modifier, topBar = { @@ -155,6 +179,7 @@ fun QueueScreen( }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> when { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { @@ -181,7 +206,10 @@ fun QueueScreen( dragging = dragging == item.position, dragOffset = if (dragging == item.position) dragOffset else 0f, onClick = { onPlay(item.position) }, - onRemove = { onRemove(item.position) }, + onRemove = { + onRemove(item.position) + showUndo("Removed from queue") + }, onSize = { rowHeight = it.toFloat() }, onDragStart = { dragging = item.position @@ -191,14 +219,19 @@ fun QueueScreen( onDragEnd = { val from = dragging if (from != null && rowHeight > 0f) { - val to = - (from + (dragOffset / rowHeight).roundToInt()) - .coerceIn(0L, (items.itemCount - 1).toLong()) - if (to != from) onMove(from, to) + val to = (from + (dragOffset / rowHeight).roundToInt()).coerceAtLeast(0) + if (to != from) { + onMove(from, to) + showUndo("Queue reordered") + } } dragging = null dragOffset = 0f }, + onDragCancel = { + dragging = null + dragOffset = 0f + }, ) } } @@ -221,6 +254,7 @@ private fun QueueRow( onDragStart: () -> Unit, onDrag: (Float) -> Unit, onDragEnd: () -> Unit, + onDragCancel: () -> Unit, ) { SongRow( song = item.item.song, @@ -248,7 +282,7 @@ private fun QueueRow( onDrag(amount.y) }, onDragEnd = onDragEnd, - onDragCancel = onDragEnd, + onDragCancel = onDragCancel, ) }, contentAlignment = Alignment.Center, diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt index a98b69e0..39214b47 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt @@ -42,7 +42,7 @@ class QueuePagingSourceTest { runTest { seedSongs(130) repository.replace(listOf(repository.songsEntry(1))) - val source = repository.pagingSource() + val source = repository.pagingSource(initialPosition = 0) val first = load(source, key = null) assertEquals((0L until 60L).toList(), first.data.map { it.position }) @@ -60,10 +60,24 @@ class QueuePagingSourceTest { 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 anEmptyQueueLoadsNothing() = runTest { - val result = load(repository.pagingSource(), key = null) + val result = load(repository.pagingSource(0), key = null) assertEquals(emptyList(), result.data) assertEquals(null, result.nextKey) diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index a85d02a2..3be83525 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -15,6 +15,8 @@ import com.subtracks.data.model.Source import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -299,6 +301,17 @@ class QueueRepositoryTest { assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot())) } + @Test + fun movingFromOutsideTheQueueChangesNothing() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + assertFalse(repository.move(from = 5, to = 0)) + assertTrue(repository.move(from = 0, to = 2)) + assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot())) + } + private suspend fun QueueRepository.snapshotAfter(entries: List): QueueSnapshot { replace(entries) return snapshot() diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index c0b99cc0..dccb6f50 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -370,7 +370,7 @@ class PlaybackControllerTest { } assertEquals(0L, controller.state.value.position) - assertTrue(handle.operations.contains("removeAt(0)")) + assertTrue(handle.operations.any { it.startsWith("setWindow") }) assertFalse(controller.state.value.hasPrevious) } @@ -437,6 +437,79 @@ class PlaybackControllerTest { ) } + @Test + fun undoRestoresARemovedTrack() { + seedAlbum(5, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + runBlocking { controller.removeAt(3) } + await { handle.itemCount == 4 } + + runBlocking { controller.undo() } + await { handle.itemCount == 5 } + + assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id }) + assertEquals( + "s3", + controller.state.value.item + ?.id, + ) + assertEquals(2L, controller.state.value.position) + } + + @Test + fun undoRestoresAReorderedQueue() { + seedAlbum(4, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.move(0, 2) } + await { handle.items.map { it.id } == listOf("s2", "s3", "s1", "s4") } + + runBlocking { controller.undo() } + await { handle.items.map { it.id } == listOf("s1", "s2", "s3", "s4") } + + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + assertEquals(0L, controller.state.value.position) + } + + @Test + fun startingANewQueueClearsTheUndo() { + seedAlbum(3, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.removeAt(1) } + await { handle.itemCount == 2 } + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + runBlocking { controller.undo() } + + assertEquals(listOf("s1", "s2", "s3"), handle.items.map { it.id }) + } + private fun seedAlbum( count: Int, sourceId: Long, diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 8960303e..60242a5d 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -237,6 +237,7 @@ class AppScreenshotTest { onPlay = {}, onRemove = {}, onMove = { _, _ -> }, + onUndo = {}, modifier = Modifier.fillMaxSize(), ) } diff --git a/docs/architecture.md b/docs/architecture.md index 96a54961..09ad3772 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; a move that crosses the window boundary reloads the window around the cursor at the current position. 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 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. ## Data model diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 0d6442f7..05885d15 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -35,7 +35,7 @@ Not done yet in this slice (deliberately): artist detail, cover-art tonal colour - (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player. - (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured). - (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position. -- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging. +- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo. - Adding to the queue from the library (play next / add to queue), and gapless format preferences. - Scrobbling and "now playing" back to the server. - Optional download/caching.