mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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 " +
|
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||||
"ORDER BY songs.disc, songs.track"
|
"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
|
@Dao
|
||||||
interface QueueDao {
|
interface QueueDao {
|
||||||
@Query("DELETE FROM queue_entries")
|
@Query("DELETE FROM queue_entries")
|
||||||
@@ -56,33 +61,34 @@ interface QueueDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
@Query("$PLAYLIST_SONGS_SQL LIMIT 1 OFFSET :offset")
|
@Query("$PLAYLIST_SONGS_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun playlistSongAt(
|
suspend fun playlistSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
): SongListItem?
|
limit: Int,
|
||||||
|
): List<SongListItem>
|
||||||
|
|
||||||
@Query("$ALBUM_SONGS_SQL LIMIT 1 OFFSET :offset")
|
@Query("$ALBUM_SONGS_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun albumSongAt(
|
suspend fun albumSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
): SongListItem?
|
limit: Int,
|
||||||
|
): List<SongListItem>
|
||||||
|
|
||||||
@Query("$SONGS_SQL LIMIT 1 OFFSET :offset")
|
@Query("$SONGS_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun songAt(
|
suspend fun songs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
): SongListItem?
|
limit: Int,
|
||||||
|
): List<SongListItem>
|
||||||
|
|
||||||
@Query(
|
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
suspend fun song(
|
suspend fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
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
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
@@ -56,14 +57,9 @@ class QueueRepository(
|
|||||||
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
|
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
|
||||||
|
|
||||||
suspend fun replace(entries: List<QueueEntry>) {
|
suspend fun replace(entries: List<QueueEntry>) {
|
||||||
db.useWriterConnection { transactor ->
|
write(entries)
|
||||||
transactor.immediateTransaction {
|
|
||||||
dao.clear()
|
|
||||||
dao.insert(entries)
|
|
||||||
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
||||||
|
|
||||||
@@ -72,7 +68,28 @@ class QueueRepository(
|
|||||||
position: Long,
|
position: Long,
|
||||||
): SongListItem? {
|
): SongListItem? {
|
||||||
val (entry, offset) = snapshot.locate(position) ?: return null
|
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(
|
suspend fun window(
|
||||||
@@ -81,28 +98,51 @@ class QueueRepository(
|
|||||||
radius: Long,
|
radius: Long,
|
||||||
): List<QueueWindowItem> {
|
): List<QueueWindowItem> {
|
||||||
if (snapshot.size == 0L) return emptyList()
|
if (snapshot.size == 0L) return emptyList()
|
||||||
val first = (center - radius).coerceAtLeast(0)
|
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
|
||||||
val last = (center + radius).coerceAtMost(snapshot.size - 1)
|
|
||||||
return (first..last).mapNotNull { position ->
|
|
||||||
itemAt(snapshot, position)?.let { QueueWindowItem(position, it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||||
|
|
||||||
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position))
|
||||||
|
|
||||||
private suspend fun itemWithin(
|
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,
|
entry: QueueEntry,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
): SongListItem? {
|
limit: Int,
|
||||||
val index = entry.offset + offset
|
): List<SongListItem> =
|
||||||
return when (entry.kind) {
|
when (entry.kind) {
|
||||||
QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index)
|
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
QueueKind.Album -> dao.albumSongAt(entry.sourceId, entry.refId, index)
|
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
QueueKind.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null
|
QueueKind.Songs -> dao.songs(entry.sourceId, offset, limit)
|
||||||
QueueKind.Songs -> dao.songAt(entry.sourceId, index)
|
QueueKind.Song -> dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
@@ -116,4 +156,68 @@ class QueueRepository(
|
|||||||
val available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
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.AlbumDetailViewModel
|
||||||
import com.subtracks.ui.library.LibraryViewModel
|
import com.subtracks.ui.library.LibraryViewModel
|
||||||
import com.subtracks.ui.library.PlaylistDetailViewModel
|
import com.subtracks.ui.library.PlaylistDetailViewModel
|
||||||
|
import com.subtracks.ui.playback.QueueViewModel
|
||||||
import com.subtracks.ui.settings.AddSourceViewModel
|
import com.subtracks.ui.settings.AddSourceViewModel
|
||||||
import com.subtracks.ui.settings.SettingsViewModel
|
import com.subtracks.ui.settings.SettingsViewModel
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -39,4 +40,5 @@ fun appModule(
|
|||||||
viewModel { AddSourceViewModel(get(), get()) }
|
viewModel { AddSourceViewModel(get(), get()) }
|
||||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> PlaylistDetailViewModel(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(
|
override fun setWindow(
|
||||||
items: List<QueueItem>,
|
items: List<QueueItem>,
|
||||||
startIndex: Int,
|
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)))
|
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 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 prepare() = controller.prepare()
|
||||||
|
|
||||||
override fun play() = controller.play()
|
override fun play() = controller.play()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ data class QueueContext(
|
|||||||
data class PlaybackState(
|
data class PlaybackState(
|
||||||
val item: QueueItem? = null,
|
val item: QueueItem? = null,
|
||||||
val context: QueueContext? = null,
|
val context: QueueContext? = null,
|
||||||
|
val position: Long? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isBuffering: Boolean = false,
|
val isBuffering: Boolean = false,
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
@@ -123,6 +124,79 @@ class PlaybackController(
|
|||||||
startPosition: Long,
|
startPosition: Long,
|
||||||
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
|
) = 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() {
|
fun togglePlayPause() {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
if (player.isEnded) {
|
if (player.isEnded) {
|
||||||
@@ -227,6 +301,7 @@ class PlaybackController(
|
|||||||
private suspend fun loadWindow(
|
private suspend fun loadWindow(
|
||||||
position: Long,
|
position: Long,
|
||||||
autoplay: Boolean,
|
autoplay: Boolean,
|
||||||
|
startPositionMs: Long = 0,
|
||||||
) {
|
) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
@@ -236,7 +311,7 @@ class PlaybackController(
|
|||||||
windowStart = window.first().position
|
windowStart = window.first().position
|
||||||
windowEnd = window.last().position
|
windowEnd = window.last().position
|
||||||
updating = true
|
updating = true
|
||||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex)
|
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||||
updating = false
|
updating = false
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
player.prepare()
|
player.prepare()
|
||||||
@@ -324,6 +399,18 @@ class PlaybackController(
|
|||||||
return windowStart + player.currentIndex
|
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 =
|
private val playerListener =
|
||||||
object : PlayerHandle.Listener {
|
object : PlayerHandle.Listener {
|
||||||
override fun onTransition() {
|
override fun onTransition() {
|
||||||
@@ -372,6 +459,7 @@ class PlaybackController(
|
|||||||
PlaybackState(
|
PlaybackState(
|
||||||
item = player.currentItem,
|
item = player.currentItem,
|
||||||
context = contextAt(position),
|
context = contextAt(position),
|
||||||
|
position = position,
|
||||||
error = lastError,
|
error = lastError,
|
||||||
isBuffering = showBuffering,
|
isBuffering = showBuffering,
|
||||||
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface PlayerHandle {
|
|||||||
fun setWindow(
|
fun setWindow(
|
||||||
items: List<QueueItem>,
|
items: List<QueueItem>,
|
||||||
startIndex: Int,
|
startIndex: Int,
|
||||||
|
startPositionMs: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun addFirst(item: QueueItem)
|
fun addFirst(item: QueueItem)
|
||||||
@@ -25,6 +26,13 @@ interface PlayerHandle {
|
|||||||
|
|
||||||
fun removeLast()
|
fun removeLast()
|
||||||
|
|
||||||
|
fun removeAt(index: Int)
|
||||||
|
|
||||||
|
fun move(
|
||||||
|
from: Int,
|
||||||
|
to: Int,
|
||||||
|
)
|
||||||
|
|
||||||
fun prepare()
|
fun prepare()
|
||||||
|
|
||||||
fun play()
|
fun play()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.subtracks.ui.library.LibraryRoute
|
|||||||
import com.subtracks.ui.library.PlaylistDetailRoute
|
import com.subtracks.ui.library.PlaylistDetailRoute
|
||||||
import com.subtracks.ui.playback.MiniPlayer
|
import com.subtracks.ui.playback.MiniPlayer
|
||||||
import com.subtracks.ui.playback.NowPlayingRoute
|
import com.subtracks.ui.playback.NowPlayingRoute
|
||||||
|
import com.subtracks.ui.playback.QueueRoute
|
||||||
import com.subtracks.ui.settings.AddSourceRoute
|
import com.subtracks.ui.settings.AddSourceRoute
|
||||||
import com.subtracks.ui.settings.SettingsRoute
|
import com.subtracks.ui.settings.SettingsRoute
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -89,6 +90,7 @@ private fun MainNavigation() {
|
|||||||
val playbackController = koinInject<PlaybackController>()
|
val playbackController = koinInject<PlaybackController>()
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
var showingNowPlaying by rememberSaveable { mutableStateOf(false) }
|
var showingNowPlaying by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showingQueue by rememberSaveable { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) { playbackController.connect() }
|
LaunchedEffect(Unit) { playbackController.connect() }
|
||||||
|
|
||||||
val playerVisible = playback.item != null
|
val playerVisible = playback.item != null
|
||||||
@@ -185,10 +187,23 @@ private fun MainNavigation() {
|
|||||||
) {
|
) {
|
||||||
NowPlayingRoute(
|
NowPlayingRoute(
|
||||||
onBack = { showingNowPlaying = false },
|
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(),
|
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,
|
coverArtId: String? = null,
|
||||||
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
|
trailingContent: (@Composable () -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -94,6 +95,7 @@ fun SongRow(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
trailingContent = trailingContent,
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.PauseCircle
|
||||||
import androidx.compose.material.icons.rounded.PlayCircle
|
import androidx.compose.material.icons.rounded.PlayCircle
|
||||||
import androidx.compose.material.icons.rounded.SkipNext
|
import androidx.compose.material.icons.rounded.SkipNext
|
||||||
@@ -49,6 +50,7 @@ import org.koin.compose.koinInject
|
|||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingRoute(
|
fun NowPlayingRoute(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onQueue: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
@@ -57,6 +59,7 @@ fun NowPlayingRoute(
|
|||||||
state = state,
|
state = state,
|
||||||
coverArt = controller.coverArt(state.item),
|
coverArt = controller.coverArt(state.item),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
onQueue = onQueue,
|
||||||
onPlayPause = controller::togglePlayPause,
|
onPlayPause = controller::togglePlayPause,
|
||||||
onNext = controller::next,
|
onNext = controller::next,
|
||||||
onPrevious = controller::previous,
|
onPrevious = controller::previous,
|
||||||
@@ -71,6 +74,7 @@ fun NowPlayingScreen(
|
|||||||
state: PlaybackState,
|
state: PlaybackState,
|
||||||
coverArt: CoverArtRef?,
|
coverArt: CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onQueue: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
@@ -90,6 +94,11 @@ fun NowPlayingScreen(
|
|||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onQueue) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.QueueMusic, contentDescription = "Queue")
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { 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))
|
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 {
|
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
||||||
replace(entries)
|
replace(entries)
|
||||||
return snapshot()
|
return snapshot()
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
override fun setWindow(
|
override fun setWindow(
|
||||||
items: List<QueueItem>,
|
items: List<QueueItem>,
|
||||||
startIndex: Int,
|
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.clear()
|
||||||
this.items += items
|
this.items += items
|
||||||
index = startIndex
|
index = startIndex
|
||||||
@@ -96,6 +97,34 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
items.removeAt(items.size - 1)
|
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() {
|
override fun prepare() {
|
||||||
operations += "prepare"
|
operations += "prepare"
|
||||||
idle = false
|
idle = false
|
||||||
|
|||||||
@@ -311,6 +311,132 @@ class PlaybackControllerTest {
|
|||||||
assertFalse(controller.state.value.hasPrevious)
|
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(
|
private fun seedAlbum(
|
||||||
count: Int,
|
count: Int,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
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.library.PlaylistDetailScreen
|
||||||
import com.subtracks.ui.playback.MiniPlayer
|
import com.subtracks.ui.playback.MiniPlayer
|
||||||
import com.subtracks.ui.playback.NowPlayingScreen
|
import com.subtracks.ui.playback.NowPlayingScreen
|
||||||
|
import com.subtracks.ui.playback.QueueScreen
|
||||||
import com.subtracks.ui.settings.AddSourceScreen
|
import com.subtracks.ui.settings.AddSourceScreen
|
||||||
import com.subtracks.ui.settings.AddSourceState
|
import com.subtracks.ui.settings.AddSourceState
|
||||||
import com.subtracks.ui.settings.SettingsScreen
|
import com.subtracks.ui.settings.SettingsScreen
|
||||||
@@ -203,6 +205,7 @@ class AppScreenshotTest {
|
|||||||
state = Fixtures.playbackState(),
|
state = Fixtures.playbackState(),
|
||||||
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
|
onQueue = {},
|
||||||
onPlayPause = {},
|
onPlayPause = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
onPrevious = {},
|
onPrevious = {},
|
||||||
@@ -215,6 +218,33 @@ class AppScreenshotTest {
|
|||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png")
|
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
|
@Test
|
||||||
fun miniPlayer() {
|
fun miniPlayer() {
|
||||||
composeRule.setContent {
|
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.
|
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
|
## 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) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
|
||||||
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
||||||
- (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position.
|
- (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position.
|
||||||
- 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.
|
- Scrobbling and "now playing" back to the server.
|
||||||
- Optional download/caching.
|
- Optional download/caching.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user