mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
add a queue view with jump, remove and reorder
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user