add a one-step undo and harden queue edits

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent e87d176155
commit 7fc142ce6f
11 changed files with 283 additions and 82 deletions
@@ -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 " +
@@ -7,13 +7,14 @@ 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? =
state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() }
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> {
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)
@@ -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<Long, QueueWindowItem> = QueuePagingSource(this)
fun pagingSource(initialPosition: Long): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this, initialPosition)
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
@@ -165,19 +173,23 @@ class QueueRepository(
private fun removeEntry(
entries: List<ResolvedQueueEntry>,
index: Long,
): List<QueueEntry> {
val result = mutableListOf<QueueEntry>()
): List<ResolvedQueueEntry> {
val result = mutableListOf<ResolvedQueueEntry>()
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
}
@@ -46,6 +46,11 @@ data class PlaybackState(
val hasPrevious: Boolean = false,
)
private data class QueueUndo(
val entries: List<QueueEntry>,
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
@@ -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<PagingData<QueueWindowItem>> =
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<Long?>(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,