add a queue view with jump, remove and reorder

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 853f37f14d
commit e87d176155
18 changed files with 941 additions and 44 deletions
@@ -14,6 +14,11 @@ private const val ALBUM_SONGS_SQL =
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
"ORDER BY songs.disc, songs.track"
private const val SONG_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.id = :songId"
@Dao
interface QueueDao {
@Query("DELETE FROM queue_entries")
@@ -56,33 +61,34 @@ interface QueueDao {
songId: String,
): Long
@Query("$PLAYLIST_SONGS_SQL LIMIT 1 OFFSET :offset")
suspend fun playlistSongAt(
@Query("$PLAYLIST_SONGS_SQL LIMIT :limit OFFSET :offset")
suspend fun playlistSongs(
sourceId: Long,
playlistId: String,
offset: Long,
): SongListItem?
limit: Int,
): List<SongListItem>
@Query("$ALBUM_SONGS_SQL LIMIT 1 OFFSET :offset")
suspend fun albumSongAt(
@Query("$ALBUM_SONGS_SQL LIMIT :limit OFFSET :offset")
suspend fun albumSongs(
sourceId: Long,
albumId: String,
offset: Long,
): SongListItem?
limit: Int,
): List<SongListItem>
@Query("$SONGS_SQL LIMIT 1 OFFSET :offset")
suspend fun songAt(
@Query("$SONGS_SQL LIMIT :limit OFFSET :offset")
suspend fun songs(
sourceId: Long,
offset: Long,
): SongListItem?
limit: Int,
): List<SongListItem>
@Query(
"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.id = :songId LIMIT 1",
)
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
suspend fun song(
sourceId: Long,
songId: String,
): SongListItem?
offset: Long,
limit: Int,
): List<SongListItem>
}
@@ -0,0 +1,27 @@
package com.subtracks.data.repo
import androidx.paging.PagingSource
import androidx.paging.PagingState
const val QUEUE_PAGE_SIZE = 60
class QueuePagingSource(
private val repository: QueueRepository,
) : 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 offset = page * QUEUE_PAGE_SIZE
val previousKey = if (page > 0) page - 1 else null
if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null)
val end = (offset + params.loadSize).coerceAtMost(snapshot.size)
return LoadResult.Page(
data = repository.range(snapshot, offset, end - 1),
prevKey = previousKey,
nextKey = if (end < snapshot.size) end / QUEUE_PAGE_SIZE else null,
)
}
}
@@ -1,5 +1,6 @@
package com.subtracks.data.repo
import androidx.paging.PagingSource
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import com.subtracks.data.db.SubtracksDatabase
@@ -56,13 +57,8 @@ class QueueRepository(
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun replace(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clear()
dao.insert(entries)
dao.setCursor(PlaybackCursor(queuePosition = 0))
}
}
write(entries)
dao.setCursor(PlaybackCursor(queuePosition = 0))
}
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
@@ -72,7 +68,28 @@ class QueueRepository(
position: Long,
): SongListItem? {
val (entry, offset) = snapshot.locate(position) ?: return null
return itemWithin(entry, offset)
return rows(entry, entry.offset + offset, 1).firstOrNull()
}
suspend fun range(
snapshot: QueueSnapshot,
first: Long,
last: Long,
): List<QueueWindowItem> {
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.entries) {
val end = start + resolved.length - 1
val entryStart = start
start += resolved.length
if (resolved.length == 0L || end < first || entryStart > last) continue
val from = maxOf(first, entryStart)
val to = minOf(last, end)
val offset = resolved.entry.offset + (from - entryStart)
rows(resolved.entry, offset, (to - from + 1).toInt())
.forEachIndexed { index, item -> items += QueueWindowItem(from + index, item) }
}
return items
}
suspend fun window(
@@ -81,30 +98,53 @@ class QueueRepository(
radius: Long,
): List<QueueWindowItem> {
if (snapshot.size == 0L) return emptyList()
val first = (center - radius).coerceAtLeast(0)
val last = (center + radius).coerceAtMost(snapshot.size - 1)
return (first..last).mapNotNull { position ->
itemAt(snapshot, position)?.let { QueueWindowItem(position, it) }
}
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
}
suspend fun removeAt(
snapshot: QueueSnapshot,
position: Long,
) = write(removeEntry(snapshot.entries, position))
suspend fun move(
from: Long,
to: Long,
) {
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)))
}
fun pagingSource(): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this)
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
private suspend fun itemWithin(
entry: QueueEntry,
offset: Long,
): SongListItem? {
val index = entry.offset + offset
return when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index)
QueueKind.Album -> dao.albumSongAt(entry.sourceId, entry.refId, index)
QueueKind.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null
QueueKind.Songs -> dao.songAt(entry.sourceId, index)
private suspend fun write(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clear()
dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) })
}
}
}
private suspend fun rows(
entry: QueueEntry,
offset: Long,
limit: Int,
): List<SongListItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Songs -> dao.songs(entry.sourceId, offset, limit)
QueueKind.Song -> dao.song(entry.sourceId, entry.refId, offset, limit)
}
private suspend fun QueueEntry.resolvedLength(): Long {
val total =
when (kind) {
@@ -116,4 +156,68 @@ class QueueRepository(
val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available
}
private fun QueueEntry.range(
start: Long,
end: Long,
) = copy(id = 0, position = 0, rangeStart = start, rangeEnd = end)
private fun removeEntry(
entries: List<ResolvedQueueEntry>,
index: Long,
): List<QueueEntry> {
val result = mutableListOf<QueueEntry>()
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)
} else {
result += resolved.entry
}
start += resolved.length
}
return result
}
private fun insertEntry(
entries: List<ResolvedQueueEntry>,
index: Long,
newEntry: QueueEntry,
): List<QueueEntry> {
val result = mutableListOf<QueueEntry>()
var start = 0L
var placed = false
for (resolved in entries) {
val end = start + resolved.length - 1
when {
!placed && index <= start -> {
result += newEntry
result += resolved.entry
placed = true
}
!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)
result += newEntry
if (offset <= last) result += resolved.entry.range(offset, last)
placed = true
}
else -> {
result += resolved.entry
}
}
start += resolved.length
}
if (!placed) result += newEntry
return result
}
}
@@ -14,6 +14,7 @@ import com.subtracks.ui.RootViewModel
import com.subtracks.ui.library.AlbumDetailViewModel
import com.subtracks.ui.library.LibraryViewModel
import com.subtracks.ui.library.PlaylistDetailViewModel
import com.subtracks.ui.playback.QueueViewModel
import com.subtracks.ui.settings.AddSourceViewModel
import com.subtracks.ui.settings.SettingsViewModel
import okhttp3.OkHttpClient
@@ -39,4 +40,5 @@ fun appModule(
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) }
}
@@ -39,7 +39,8 @@ class Media3PlayerHandle(
override fun setWindow(
items: List<QueueItem>,
startIndex: Int,
) = controller.setMediaItems(items.map(::toMediaItem), startIndex, 0)
startPositionMs: Long,
) = controller.setMediaItems(items.map(::toMediaItem), startIndex, startPositionMs)
override fun addFirst(item: QueueItem) = controller.addMediaItems(0, listOf(toMediaItem(item)))
@@ -49,6 +50,13 @@ class Media3PlayerHandle(
override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1)
override fun removeAt(index: Int) = controller.removeMediaItem(index)
override fun move(
from: Int,
to: Int,
) = controller.moveMediaItem(from, to)
override fun prepare() = controller.prepare()
override fun play() = controller.play()
@@ -36,6 +36,7 @@ data class QueueContext(
data class PlaybackState(
val item: QueueItem? = null,
val context: QueueContext? = null,
val position: Long? = null,
val error: String? = null,
val isBuffering: Boolean = false,
val isPlaying: Boolean = false,
@@ -123,6 +124,79 @@ class PlaybackController(
startPosition: Long,
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
fun playAt(position: Long) {
scope.launch {
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock
val player = player ?: return@withLock
val target = position.coerceIn(0, snapshot.size - 1)
windowJob?.cancel()
queueRepository.setCursor(target)
if (target in windowStart..windowEnd) {
player.ensurePrepared()
player.seekToIndex((target - windowStart).toInt())
player.play()
refresh(target)
} else {
loadWindow(target, autoplay = true)
}
}
}
}
suspend fun removeAt(position: Long) =
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (position !in 0 until snapshot.size) return@withLock
val player = player ?: return@withLock
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
stopLocked()
return@withLock
}
val current = currentPosition() ?: return@withLock
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
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--
}
queueRepository.setCursor(target)
refresh(target)
}
suspend fun move(
from: Long,
to: Long,
) = startLock.withLock {
if (from == to) return@withLock
val snapshot = snapshot ?: return@withLock
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)
this.snapshot = queueRepository.snapshot()
windowJob?.cancel()
if (from in windowStart..windowEnd && to in windowStart..windowEnd) {
updating = true
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
updating = false
} else {
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
}
queueRepository.setCursor(target)
refresh(target)
}
fun togglePlayPause() {
val player = player ?: return
if (player.isEnded) {
@@ -227,6 +301,7 @@ class PlaybackController(
private suspend fun loadWindow(
position: Long,
autoplay: Boolean,
startPositionMs: Long = 0,
) {
val player = player ?: return
val snapshot = snapshot ?: return
@@ -236,7 +311,7 @@ class PlaybackController(
windowStart = window.first().position
windowEnd = window.last().position
updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex)
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false
if (autoplay) {
player.prepare()
@@ -324,6 +399,18 @@ class PlaybackController(
return windowStart + player.currentIndex
}
private fun movedCursor(
current: Long,
from: Long,
to: Long,
): Long =
when {
from == current -> to
from < current && to >= current -> current - 1
from > current && to <= current -> current + 1
else -> current
}
private val playerListener =
object : PlayerHandle.Listener {
override fun onTransition() {
@@ -372,6 +459,7 @@ class PlaybackController(
PlaybackState(
item = player.currentItem,
context = contextAt(position),
position = position,
error = lastError,
isBuffering = showBuffering,
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
@@ -15,6 +15,7 @@ interface PlayerHandle {
fun setWindow(
items: List<QueueItem>,
startIndex: Int,
startPositionMs: Long = 0,
)
fun addFirst(item: QueueItem)
@@ -25,6 +26,13 @@ interface PlayerHandle {
fun removeLast()
fun removeAt(index: Int)
fun move(
from: Int,
to: Int,
)
fun prepare()
fun play()
@@ -41,6 +41,7 @@ import com.subtracks.ui.library.LibraryRoute
import com.subtracks.ui.library.PlaylistDetailRoute
import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingRoute
import com.subtracks.ui.playback.QueueRoute
import com.subtracks.ui.settings.AddSourceRoute
import com.subtracks.ui.settings.SettingsRoute
import kotlinx.coroutines.flow.SharingStarted
@@ -89,6 +90,7 @@ private fun MainNavigation() {
val playbackController = koinInject<PlaybackController>()
val playback by playbackController.state.collectAsStateWithLifecycle()
var showingNowPlaying by rememberSaveable { mutableStateOf(false) }
var showingQueue by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) { playbackController.connect() }
val playerVisible = playback.item != null
@@ -185,10 +187,23 @@ private fun MainNavigation() {
) {
NowPlayingRoute(
onBack = { showingNowPlaying = false },
onQueue = { showingQueue = true },
modifier = Modifier.fillMaxSize(),
)
}
AnimatedVisibility(
visible = showingQueue,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(NOW_PLAYING_DURATION_MS)),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(NOW_PLAYING_DURATION_MS)),
) {
QueueRoute(
onBack = { showingQueue = false },
modifier = Modifier.fillMaxSize(),
)
}
}
BackHandler(enabled = showingNowPlaying) { showingNowPlaying = false }
BackHandler(enabled = showingQueue) { showingQueue = false }
BackHandler(enabled = showingNowPlaying && !showingQueue) { showingNowPlaying = false }
}
@@ -78,6 +78,7 @@ fun SongRow(
coverArtId: String? = null,
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
isPlaying: Boolean = false,
trailingContent: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
) {
ListItem(
@@ -94,6 +95,7 @@ fun SongRow(
} else {
null
},
trailingContent = trailingContent,
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.PauseCircle
import androidx.compose.material.icons.rounded.PlayCircle
import androidx.compose.material.icons.rounded.SkipNext
@@ -49,6 +50,7 @@ import org.koin.compose.koinInject
@Composable
fun NowPlayingRoute(
onBack: () -> Unit,
onQueue: () -> Unit,
modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(),
) {
@@ -57,6 +59,7 @@ fun NowPlayingRoute(
state = state,
coverArt = controller.coverArt(state.item),
onBack = onBack,
onQueue = onQueue,
onPlayPause = controller::togglePlayPause,
onNext = controller::next,
onPrevious = controller::previous,
@@ -71,6 +74,7 @@ fun NowPlayingScreen(
state: PlaybackState,
coverArt: CoverArtRef?,
onBack: () -> Unit,
onQueue: () -> Unit,
onPlayPause: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
@@ -90,6 +94,11 @@ fun NowPlayingScreen(
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onQueue) {
Icon(Icons.AutoMirrored.Rounded.QueueMusic, contentDescription = "Queue")
}
},
)
},
) { padding ->
@@ -0,0 +1,267 @@
package com.subtracks.ui.playback
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
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
import com.subtracks.data.repo.QUEUE_PAGE_SIZE
import com.subtracks.data.repo.QueuePagingSource
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.data.repo.SourceRepository
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,
) : ViewModel() {
private var source: QueuePagingSource? = null
val items: Flow<PagingData<QueueWindowItem>> =
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
QueuePagingSource(queueRepository).also { source = it }
}.flow.cachedIn(viewModelScope)
fun play(position: Long) {
viewModelScope.launch { playbackController.playAt(position) }
}
fun remove(position: Long) {
viewModelScope.launch {
playbackController.removeAt(position)
source?.invalidate()
}
}
fun move(
from: Long,
to: Long,
) {
if (from == to) return
viewModelScope.launch {
playbackController.move(from, to)
source?.invalidate()
}
}
}
@Composable
fun QueueRoute(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: QueueViewModel = koinViewModel(),
controller: PlaybackController = koinInject(),
sourceRepository: SourceRepository = koinInject(),
) {
val playback by controller.state.collectAsStateWithLifecycle()
QueueScreen(
items = viewModel.items.collectAsLazyPagingItems(),
currentPosition = playback.position,
coverArt = sourceRepository::coverArt,
onBack = onBack,
onPlay = viewModel::play,
onRemove = viewModel::remove,
onMove = viewModel::move,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(
items: LazyPagingItems<QueueWindowItem>,
currentPosition: Long?,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
onPlay: (Long) -> Unit,
onRemove: (Long) -> Unit,
onMove: (Long, Long) -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
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 position = currentPosition ?: return@LaunchedEffect
if (!centered && position.toInt() < items.itemCount) {
listState.scrollToItem(position.toInt())
centered = true
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Queue") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
LoadingState(Modifier.padding(padding))
}
items.itemCount == 0 -> {
EmptyState("The queue is empty.", Modifier.padding(padding))
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(count = items.itemCount) { index ->
val item = items[index]
if (item != null) {
QueueRow(
item = item,
isPlaying = item.position == currentPosition,
coverArt = coverArt,
dragging = dragging == item.position,
dragOffset = if (dragging == item.position) dragOffset else 0f,
onClick = { onPlay(item.position) },
onRemove = { onRemove(item.position) },
onSize = { rowHeight = it.toFloat() },
onDragStart = {
dragging = item.position
dragOffset = 0f
},
onDrag = { dragOffset += it },
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)
}
dragging = null
dragOffset = 0f
},
)
}
}
}
}
}
}
}
@Composable
private fun QueueRow(
item: QueueWindowItem,
isPlaying: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?,
dragging: Boolean,
dragOffset: Float,
onClick: () -> Unit,
onRemove: () -> Unit,
onSize: (Int) -> Unit,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
) {
SongRow(
song = item.item.song,
coverArtId = item.item.coverArt,
coverArt = coverArt,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Remove from queue",
modifier = Modifier.size(20.dp),
)
}
Box(
modifier =
Modifier
.size(40.dp)
.pointerInput(item.position) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, amount ->
change.consume()
onDrag(amount.y)
},
onDragEnd = onDragEnd,
onDragCancel = onDragEnd,
)
},
contentAlignment = Alignment.Center,
) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
},
modifier =
Modifier
.zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = dragOffset }
.onSizeChanged { size -> onSize(size.height) }
.clickable(onClick = onClick),
)
}
@@ -0,0 +1,104 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QueuePagingSourceTest {
private lateinit var db: SubtracksDatabase
private lateinit var repository: QueueRepository
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
repository = QueueRepository(db)
}
@After
fun tearDown() {
db.close()
}
@Test
fun pagesAreContiguousAndDoNotOverlap() =
runTest {
seedSongs(130)
repository.replace(listOf(repository.songsEntry(1)))
val source = repository.pagingSource()
val first = load(source, key = null)
assertEquals((0L until 60L).toList(), first.data.map { it.position })
assertEquals(1L, first.nextKey)
assertEquals(null, first.prevKey)
val second = load(source, key = 1L)
assertEquals((60L until 120L).toList(), second.data.map { it.position })
assertEquals(2L, second.nextKey)
assertEquals(0L, second.prevKey)
val third = load(source, key = 2L)
assertEquals((120L until 130L).toList(), third.data.map { it.position })
assertEquals(null, third.nextKey)
assertEquals(1L, third.prevKey)
}
@Test
fun anEmptyQueueLoadsNothing() =
runTest {
val result = load(repository.pagingSource(), key = null)
assertEquals(emptyList<QueueWindowItem>(), result.data)
assertEquals(null, result.nextKey)
assertEquals(null, result.prevKey)
}
private suspend fun load(
source: PagingSource<Long, QueueWindowItem>,
key: Long?,
): PagingSource.LoadResult.Page<Long, QueueWindowItem> {
val params = PagingSource.LoadParams.Refresh<Long>(key, QUEUE_PAGE_SIZE, false)
return source.load(params) as PagingSource.LoadResult.Page<Long, QueueWindowItem>
}
private suspend fun seedSongs(count: Int) {
db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
}
@@ -228,6 +228,77 @@ class QueueRepositoryTest {
assertEquals(listOf("s4"), resolveAll(snapshot))
}
@Test
fun rangeResolvesASliceSpanningEntries() =
runTest {
seedLibrary()
val entries = listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))
val snapshot = repository.snapshotAfter(entries)
val range = repository.range(snapshot, first = 1, last = 3)
assertEquals(listOf(1L, 2L, 3L), range.map { it.position })
assertEquals(listOf("s2", "s3", "s4"), range.map { it.item.song.id })
}
@Test
fun removingInsideAnEntrySplitsItIntoTwoRanges() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(snapshot, 1)
assertEquals(listOf("s1", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingTheFirstOrLastTrackKeepsTheRemainingRange() =
runTest {
seedLibrary()
val first = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(first, 0)
assertEquals(listOf("s2", "s3"), resolveAll(repository.snapshot()))
val last = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.removeAt(last, 2)
assertEquals(listOf("s1", "s2"), resolveAll(repository.snapshot()))
}
@Test
fun removingASongEntryDropsIt() =
runTest {
seedLibrary()
val snapshot =
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.removeAt(snapshot, 3)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun movingWithinAnEntryReordersTheQueue() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.move(from = 0, to = 2)
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
}
@Test
fun movingAcrossEntriesKeepsTheRestInOrder() =
runTest {
seedLibrary()
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")))
repository.move(from = 0, to = 3)
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
}
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
replace(entries)
return snapshot()
@@ -61,8 +61,9 @@ class FakePlayerHandle : PlayerHandle {
override fun setWindow(
items: List<QueueItem>,
startIndex: Int,
startPositionMs: Long,
) {
operations += "setWindow(size=${items.size}, start=$startIndex)"
operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)"
this.items.clear()
this.items += items
index = startIndex
@@ -96,6 +97,34 @@ class FakePlayerHandle : PlayerHandle {
items.removeAt(items.size - 1)
}
override fun removeAt(index: Int) {
operations += "removeAt($index)"
items.removeAt(index)
this.index =
when {
index < this.index -> this.index - 1
index == this.index -> this.index.coerceAtMost(items.size - 1)
else -> this.index
}
notifyEvents()
}
override fun move(
from: Int,
to: Int,
) {
operations += "move($from, $to)"
items.add(to, items.removeAt(from))
index =
when {
index == from -> to
from < index && to >= index -> index - 1
from > index && to <= index -> index + 1
else -> index
}
notifyEvents()
}
override fun prepare() {
operations += "prepare"
idle = false
@@ -311,6 +311,132 @@ class PlaybackControllerTest {
assertFalse(controller.state.value.hasPrevious)
}
@Test
fun jumpingToAVisibleItemSeeksWithinTheWindow() {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.playAt(10)
await { controller.state.value.position == 10L }
assertTrue(handle.operations.contains("seekToIndex(10)"))
assertEquals(
"s11",
controller.state.value.item
?.id,
)
}
@Test
fun jumpingOutsideTheWindowReloadsAroundTheTarget() {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.playAt(40)
await {
controller.state.value.item
?.id == "s41"
}
assertEquals(40L, controller.state.value.position)
assertTrue(handle.operations.any { it.startsWith("setWindow") })
assertTrue(controller.state.value.hasPrevious)
}
@Test
fun removingTheCurrentTrackPlaysTheNextOne() {
seedAlbum(5, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.removeAt(0) }
await {
controller.state.value.item
?.id == "s2"
}
assertEquals(0L, controller.state.value.position)
assertTrue(handle.operations.contains("removeAt(0)"))
assertFalse(controller.state.value.hasPrevious)
}
@Test
fun removingALaterTrackKeepsTheCurrentOne() {
seedAlbum(5, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.removeAt(3) }
await { handle.itemCount == 4 }
assertEquals(
"s1",
controller.state.value.item
?.id,
)
assertEquals(listOf("s1", "s2", "s3", "s5"), handle.items.map { it.id })
}
@Test
fun movingATrackReordersTheWindow() {
seedAlbum(4, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
runBlocking { controller.move(0, 2) }
await { controller.state.value.position == 2L }
assertEquals(listOf("s2", "s3", "s1", "s4"), handle.items.map { it.id })
assertEquals(
"s1",
controller.state.value.item
?.id,
)
}
@Test
fun movingALaterTrackBeforeTheCurrentShiftsTheCursor() {
seedAlbum(4, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
runBlocking { controller.move(3, 0) }
await { controller.state.value.position == 3L }
assertEquals(listOf("s4", "s1", "s2", "s3"), handle.items.map { it.id })
assertEquals(
"s3",
controller.state.value.item
?.id,
)
}
private fun seedAlbum(
count: Int,
sourceId: Long,
@@ -34,6 +34,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.ui.library.ALBUM_COVER_TAG
@@ -43,6 +44,7 @@ import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.PlaylistDetailScreen
import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingScreen
import com.subtracks.ui.playback.QueueScreen
import com.subtracks.ui.settings.AddSourceScreen
import com.subtracks.ui.settings.AddSourceState
import com.subtracks.ui.settings.SettingsScreen
@@ -203,6 +205,7 @@ class AppScreenshotTest {
state = Fixtures.playbackState(),
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
onBack = {},
onQueue = {},
onPlayPause = {},
onNext = {},
onPrevious = {},
@@ -215,6 +218,33 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
}
@Test
fun queue() {
composeRule.setContent {
SubtracksTheme {
QueueScreen(
items =
remember {
flowOf(
PagingData.from(
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) },
),
)
}.collectAsLazyPagingItems(),
currentPosition = 1,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onPlay = {},
onRemove = {},
onMove = { _, _ -> },
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().captureRoboImage("src/test/screenshots/queue.png")
}
@Test
fun miniPlayer() {
composeRule.setContent {
+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. Timed lyrics/gapless format preferences, bitrate/format preferences, scrobbling and downloads are not wired yet, and the queue view (reordering) is not built, though the schema supports it.
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.
## Data model
+2 -1
View File
@@ -35,7 +35,8 @@ 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.
- The queue view (add to queue / play next / reorder), and gapless format preferences.
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging.
- 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.