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 " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.albumId = :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 = private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -7,13 +7,14 @@ const val QUEUE_PAGE_SIZE = 60
class QueuePagingSource( class QueuePagingSource(
private val repository: QueueRepository, private val repository: QueueRepository,
private val initialPosition: Long,
) : PagingSource<Long, QueueWindowItem>() { ) : PagingSource<Long, QueueWindowItem>() {
override fun getRefreshKey(state: PagingState<Long, QueueWindowItem>): Long? = override fun getRefreshKey(state: PagingState<Long, QueueWindowItem>): Long? =
state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() } state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() }
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> { override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> {
val snapshot = repository.snapshot() 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 offset = page * QUEUE_PAGE_SIZE
val previousKey = if (page > 0) page - 1 else null val previousKey = if (page > 0) page - 1 else null
if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null) if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null)
@@ -1,7 +1,9 @@
package com.subtracks.data.repo package com.subtracks.data.repo
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room3.deferredTransaction
import androidx.room3.immediateTransaction import androidx.room3.immediateTransaction
import androidx.room3.useReaderConnection
import androidx.room3.useWriterConnection import androidx.room3.useWriterConnection
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.PlaybackCursor
@@ -61,7 +63,12 @@ class QueueRepository(
dao.setCursor(PlaybackCursor(queuePosition = 0)) 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( suspend fun itemAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
@@ -104,21 +111,22 @@ class QueueRepository(
suspend fun removeAt( suspend fun removeAt(
snapshot: QueueSnapshot, snapshot: QueueSnapshot,
position: Long, position: Long,
) = write(removeEntry(snapshot.entries, position)) ) = write(removeEntry(snapshot.entries, position).map { it.entry })
suspend fun move( suspend fun move(
from: Long, from: Long,
to: Long, to: Long,
) { ): Boolean {
val before = snapshot() val before = snapshot()
val located = before.locate(from) ?: return val located = before.locate(from) ?: return false
val song = itemAt(before, from) ?: return val song = itemAt(before, from) ?: return false
write(removeEntry(before.entries, from)) val removed = removeEntry(before.entries, from)
val after = snapshot() val moved = songEntry(located.first.sourceId, song.song.id)
write(insertEntry(after.entries, to.coerceIn(0, after.size), 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 suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
@@ -165,19 +173,23 @@ class QueueRepository(
private fun removeEntry( private fun removeEntry(
entries: List<ResolvedQueueEntry>, entries: List<ResolvedQueueEntry>,
index: Long, index: Long,
): List<QueueEntry> { ): List<ResolvedQueueEntry> {
val result = mutableListOf<QueueEntry>() val result = mutableListOf<ResolvedQueueEntry>()
var start = 0L var start = 0L
for (resolved in entries) { for (resolved in entries) {
val end = start + resolved.length - 1 val end = start + resolved.length - 1
if (index in start..end) { if (index in start..end) {
val offset = resolved.entry.offset + (index - start) val offset = resolved.entry.offset + (index - start)
val first = resolved.entry.offset val first = resolved.entry.offset
val last = resolved.entry.offset + resolved.length - 1 val last = first + resolved.length - 1
if (offset > first) result += resolved.entry.range(first, offset - 1) if (offset > first) {
if (offset < last) result += resolved.entry.range(offset + 1, last) result += ResolvedQueueEntry(resolved.entry.range(first, offset - 1), offset - first)
}
if (offset < last) {
result += ResolvedQueueEntry(resolved.entry.range(offset + 1, last), last - offset)
}
} else { } else {
result += resolved.entry result += resolved
} }
start += resolved.length start += resolved.length
} }
@@ -202,12 +214,11 @@ class QueueRepository(
} }
!placed && index in start..end -> { !placed && index in start..end -> {
val offset = resolved.entry.offset + (index - start) val cut = index - start
val first = resolved.entry.offset val offset = resolved.entry.offset
val last = resolved.entry.offset + resolved.length - 1 if (cut > 0) result += resolved.entry.range(offset, offset + cut - 1)
if (offset > first) result += resolved.entry.range(first, offset - 1)
result += newEntry 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 placed = true
} }
@@ -46,6 +46,11 @@ data class PlaybackState(
val hasPrevious: Boolean = false, val hasPrevious: Boolean = false,
) )
private data class QueueUndo(
val entries: List<QueueEntry>,
val cursor: Long,
)
class PlaybackController( class PlaybackController(
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
@@ -72,6 +77,7 @@ class PlaybackController(
private var windowStart = 0L private var windowStart = 0L
private var windowEnd = -1L private var windowEnd = -1L
private var updating = false private var updating = false
private var lastEdit: QueueUndo? = null
init { init {
scope.launch { scope.launch {
@@ -137,6 +143,7 @@ class PlaybackController(
player.ensurePrepared() player.ensurePrepared()
player.seekToIndex((target - windowStart).toInt()) player.seekToIndex((target - windowStart).toInt())
player.play() player.play()
shiftWindowLocked(target)
refresh(target) refresh(target)
} else { } else {
loadWindow(target, autoplay = true) loadWindow(target, autoplay = true)
@@ -150,24 +157,42 @@ class PlaybackController(
val snapshot = snapshot ?: return@withLock val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) return@withLock if (position !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor())
if (snapshot.size == 1L) { if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position) queueRepository.removeAt(snapshot, position)
stopLocked() stopLocked()
lastEdit = undo
return@withLock return@withLock
} }
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2) val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
val playing = player.playWhenReady
queueRepository.removeAt(snapshot, position) queueRepository.removeAt(snapshot, position)
this.snapshot = queueRepository.snapshot() this.snapshot = queueRepository.snapshot()
windowJob?.cancel() windowJob?.cancel()
if (position < windowStart) { lastEdit = undo
windowStart-- when {
windowEnd-- position == current -> {
} else if (position <= windowEnd) { loadWindow(target, autoplay = playing, startPositionMs = 0)
updating = true }
player.removeAt((position - windowStart).toInt())
updating = false position < windowStart -> {
windowEnd-- windowStart--
windowEnd--
shiftWindowLocked(target)
}
position <= windowEnd -> {
updating = true
player.removeAt((position - windowStart).toInt())
updating = false
windowEnd--
shiftWindowLocked(target)
}
else -> {
shiftWindowLocked(target)
}
} }
queueRepository.setCursor(target) queueRepository.setCursor(target)
refresh(target) refresh(target)
@@ -182,10 +207,12 @@ class PlaybackController(
if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock val player = player ?: return@withLock
val current = currentPosition() ?: return@withLock val current = currentPosition() ?: return@withLock
val target = movedCursor(current, from, to) val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor())
queueRepository.move(from, to) if (!queueRepository.move(from, to)) return@withLock
this.snapshot = queueRepository.snapshot() this.snapshot = queueRepository.snapshot()
val target = movedCursor(current, from, to)
windowJob?.cancel() windowJob?.cancel()
lastEdit = undo
if (from in windowStart..windowEnd && to in windowStart..windowEnd) { if (from in windowStart..windowEnd && to in windowStart..windowEnd) {
updating = true updating = true
player.move((from - windowStart).toInt(), (to - windowStart).toInt()) player.move((from - windowStart).toInt(), (to - windowStart).toInt())
@@ -193,10 +220,34 @@ class PlaybackController(
} else { } else {
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
} }
shiftWindowLocked(target)
queueRepository.setCursor(target) queueRepository.setCursor(target)
refresh(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() { fun togglePlayPause() {
val player = player ?: return val player = player ?: return
if (player.isEnded) { if (player.isEnded) {
@@ -242,6 +293,7 @@ class PlaybackController(
windowEnd = -1 windowEnd = -1
lastDurationMs = 0 lastDurationMs = 0
lastError = null lastError = null
lastEdit = null
queueRepository.replace(entries) queueRepository.replace(entries)
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
this.snapshot = snapshot this.snapshot = snapshot
@@ -290,6 +342,7 @@ class PlaybackController(
queueSourceId = null queueSourceId = null
windowStart = 0 windowStart = 0
windowEnd = -1 windowEnd = -1
lastEdit = null
player?.run { player?.run {
stop() stop()
clear() clear()
@@ -359,39 +412,40 @@ class PlaybackController(
} }
} }
private suspend fun shiftWindow(center: Long) = private suspend fun shiftWindow(center: Long) = startLock.withLock { shiftWindowLocked(center) }
startLock.withLock {
val player = player ?: return@withLock private suspend fun shiftWindowLocked(center: Long) {
val snapshot = queueRepository.snapshot() val player = player ?: return
this.snapshot = snapshot val snapshot = queueRepository.snapshot()
if (snapshot.size == 0L) return@withLock this.snapshot = snapshot
val target = center.coerceIn(0, snapshot.size - 1) if (snapshot.size == 0L) return
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0) val target = center.coerceIn(0, snapshot.size - 1)
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1) val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
updating = true val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
try { updating = true
while (windowStart > desiredStart) { try {
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break while (windowStart > desiredStart) {
player.addFirst(item.toQueueItem()) val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
windowStart-- 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
} }
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? { private fun currentPosition(): Long? {
val player = player ?: return null val player = player ?: return null
@@ -18,6 +18,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,6 +30,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -41,7 +46,6 @@ import androidx.paging.LoadState
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.data.model.CoverArtRef 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.EmptyState
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalCoroutinesApi::class)
class QueueViewModel( class QueueViewModel(
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
@@ -70,8 +72,8 @@ class QueueViewModel(
val items: Flow<PagingData<QueueWindowItem>> = val items: Flow<PagingData<QueueWindowItem>> =
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) { Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
QueuePagingSource(queueRepository).also { source = it } QueuePagingSource(queueRepository, playbackController.state.value.position ?: 0L).also { source = it }
}.flow.cachedIn(viewModelScope) }.flow
fun play(position: Long) { fun play(position: Long) {
viewModelScope.launch { playbackController.playAt(position) } viewModelScope.launch { playbackController.playAt(position) }
@@ -94,6 +96,13 @@ class QueueViewModel(
source?.invalidate() source?.invalidate()
} }
} }
fun undo() {
viewModelScope.launch {
playbackController.undo()
source?.invalidate()
}
}
} }
@Composable @Composable
@@ -113,6 +122,7 @@ fun QueueRoute(
onPlay = viewModel::play, onPlay = viewModel::play,
onRemove = viewModel::remove, onRemove = viewModel::remove,
onMove = viewModel::move, onMove = viewModel::move,
onUndo = viewModel::undo,
modifier = modifier, modifier = modifier,
) )
} }
@@ -127,22 +137,36 @@ fun QueueScreen(
onPlay: (Long) -> Unit, onPlay: (Long) -> Unit,
onRemove: (Long) -> Unit, onRemove: (Long) -> Unit,
onMove: (Long, Long) -> Unit, onMove: (Long, Long) -> Unit,
onUndo: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var rowHeight by remember { mutableFloatStateOf(0f) } var rowHeight by remember { mutableFloatStateOf(0f) }
var dragging by remember { mutableStateOf<Long?>(null) } var dragging by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { mutableFloatStateOf(0f) } var dragOffset by remember { mutableFloatStateOf(0f) }
var centered by remember { mutableStateOf(false) } 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 val position = currentPosition ?: return@LaunchedEffect
if (!centered && position.toInt() < items.itemCount) { val first = firstPosition ?: return@LaunchedEffect
listState.scrollToItem(position.toInt()) val index = position - first
if (!centered && index >= 0 && index < items.itemCount) {
listState.scrollToItem(index.toInt())
centered = true 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( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -155,6 +179,7 @@ fun QueueScreen(
}, },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding -> ) { padding ->
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -181,7 +206,10 @@ fun QueueScreen(
dragging = dragging == item.position, dragging = dragging == item.position,
dragOffset = if (dragging == item.position) dragOffset else 0f, dragOffset = if (dragging == item.position) dragOffset else 0f,
onClick = { onPlay(item.position) }, onClick = { onPlay(item.position) },
onRemove = { onRemove(item.position) }, onRemove = {
onRemove(item.position)
showUndo("Removed from queue")
},
onSize = { rowHeight = it.toFloat() }, onSize = { rowHeight = it.toFloat() },
onDragStart = { onDragStart = {
dragging = item.position dragging = item.position
@@ -191,14 +219,19 @@ fun QueueScreen(
onDragEnd = { onDragEnd = {
val from = dragging val from = dragging
if (from != null && rowHeight > 0f) { if (from != null && rowHeight > 0f) {
val to = val to = (from + (dragOffset / rowHeight).roundToInt()).coerceAtLeast(0)
(from + (dragOffset / rowHeight).roundToInt()) if (to != from) {
.coerceIn(0L, (items.itemCount - 1).toLong()) onMove(from, to)
if (to != from) onMove(from, to) showUndo("Queue reordered")
}
} }
dragging = null dragging = null
dragOffset = 0f dragOffset = 0f
}, },
onDragCancel = {
dragging = null
dragOffset = 0f
},
) )
} }
} }
@@ -221,6 +254,7 @@ private fun QueueRow(
onDragStart: () -> Unit, onDragStart: () -> Unit,
onDrag: (Float) -> Unit, onDrag: (Float) -> Unit,
onDragEnd: () -> Unit, onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
) { ) {
SongRow( SongRow(
song = item.item.song, song = item.item.song,
@@ -248,7 +282,7 @@ private fun QueueRow(
onDrag(amount.y) onDrag(amount.y)
}, },
onDragEnd = onDragEnd, onDragEnd = onDragEnd,
onDragCancel = onDragEnd, onDragCancel = onDragCancel,
) )
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -42,7 +42,7 @@ class QueuePagingSourceTest {
runTest { runTest {
seedSongs(130) seedSongs(130)
repository.replace(listOf(repository.songsEntry(1))) repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource() val source = repository.pagingSource(initialPosition = 0)
val first = load(source, key = null) val first = load(source, key = null)
assertEquals((0L until 60L).toList(), first.data.map { it.position }) assertEquals((0L until 60L).toList(), first.data.map { it.position })
@@ -60,10 +60,24 @@ class QueuePagingSourceTest {
assertEquals(1L, third.prevKey) 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 @Test
fun anEmptyQueueLoadsNothing() = fun anEmptyQueueLoadsNothing() =
runTest { runTest {
val result = load(repository.pagingSource(), key = null) val result = load(repository.pagingSource(0), key = null)
assertEquals(emptyList<QueueWindowItem>(), result.data) assertEquals(emptyList<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey) assertEquals(null, result.nextKey)
@@ -15,6 +15,8 @@ import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -299,6 +301,17 @@ class QueueRepositoryTest {
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot())) 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<QueueEntry>): QueueSnapshot { private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
replace(entries) replace(entries)
return snapshot() return snapshot()
@@ -370,7 +370,7 @@ class PlaybackControllerTest {
} }
assertEquals(0L, controller.state.value.position) assertEquals(0L, controller.state.value.position)
assertTrue(handle.operations.contains("removeAt(0)")) assertTrue(handle.operations.any { it.startsWith("setWindow") })
assertFalse(controller.state.value.hasPrevious) 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( private fun seedAlbum(
count: Int, count: Int,
sourceId: Long, sourceId: Long,
@@ -237,6 +237,7 @@ class AppScreenshotTest {
onPlay = {}, onPlay = {},
onRemove = {}, onRemove = {},
onMove = { _, _ -> }, onMove = { _, _ -> },
onUndo = {},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
+1 -1
View File
@@ -88,7 +88,7 @@ Because the resolver reports *which entry* a position falls in, `PlaybackState`
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it pages with queries bounded to one page per entry (never the whole queue) and rewrites `queue_entries` to edit. Removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; 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 ## Data model
+1 -1
View File
@@ -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) 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) 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) 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. - Adding to the queue from the library (play next / add to queue), and gapless format preferences.
- Scrobbling and "now playing" back to the server. - Scrobbling and "now playing" back to the server.
- Optional download/caching. - Optional download/caching.