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,
@@ -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<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey)
@@ -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<QueueEntry>): QueueSnapshot {
replace(entries)
return snapshot()
@@ -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,
@@ -237,6 +237,7 @@ class AppScreenshotTest {
onPlay = {},
onRemove = {},
onMove = { _, _ -> },
onUndo = {},
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.
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
+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) 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.