mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
add a one-step undo and harden queue edits
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user