add a separate, editable up-next queue (#61)

Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.

PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent 50b99979e1
commit d5a9a11cef
18 changed files with 2038 additions and 145 deletions
@@ -144,6 +144,25 @@ val MIGRATION_14_15 =
}
}
val MIGRATION_15_16 =
object : Migration(15, 16) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `up_next_entries` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
"`refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `up_next_entries` (`sourceId`)",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `up_next_entries` (`position`)",
)
connection.execSQL("ALTER TABLE `playback_cursor` ADD COLUMN `upNextAnchor` INTEGER NOT NULL DEFAULT 0")
}
}
private suspend fun rebuildTable(
connection: SQLiteConnection,
table: String,
@@ -232,4 +251,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
)
@@ -8,6 +8,7 @@ import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
private const val ALBUM_SONGS_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -31,6 +32,15 @@ interface QueueDao {
@Query("SELECT * FROM queue_entries ORDER BY position, id")
suspend fun entries(): List<QueueEntry>
@Query("DELETE FROM up_next_entries")
suspend fun clearUpNext()
@Insert
suspend fun insertUpNext(entries: List<UpNextEntry>)
@Query("SELECT * FROM up_next_entries ORDER BY position, id")
suspend fun upNextEntries(): List<UpNextEntry>
@Query("SELECT * FROM shuffle_order ORDER BY sequence")
suspend fun shuffleOrder(): List<ShuffleOrder>
@@ -16,6 +16,7 @@ import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@Database(
entities = [
@@ -28,11 +29,12 @@ import com.subtracks.data.model.SubsonicSource
PlaylistSong::class,
Song::class,
QueueEntry::class,
UpNextEntry::class,
PlaybackCursor::class,
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 15,
version = 16,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -265,6 +265,39 @@ data class QueueEntry(
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
}
@Entity(
tableName = "up_next_entries",
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId"), Index("position")],
)
data class UpNextEntry(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val position: Long,
val sourceId: Long,
val kind: QueueKind,
val refId: String,
val rangeStart: Long? = null,
val rangeEnd: Long? = null,
) {
val offset: Long get() = rangeStart ?: 0
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
companion object {
fun from(entry: QueueEntry) =
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
}
}
@Entity(tableName = "playback_cursor")
data class PlaybackCursor(
@PrimaryKey val id: Long = 1,
@@ -273,6 +306,7 @@ data class PlaybackCursor(
@ColumnInfo(defaultValue = "0") val repeatMode: Int = 0,
@ColumnInfo(defaultValue = "0") val shuffleSeed: Long = 0,
@ColumnInfo(defaultValue = "0") val positionMs: Long = 0,
@ColumnInfo(defaultValue = "0") val upNextAnchor: Long = 0,
)
@Entity(tableName = "shuffle_order")
@@ -10,6 +10,7 @@ import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.ShuffleOrder
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.UpNextEntry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -29,6 +30,13 @@ private data class EntryLengthKey(
val count: Long?,
)
private data class LoadedSnapshot(
val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry>,
val shuffleOrder: LongArray?,
val anchor: Long,
)
data class ResolvedQueueEntry(
val entry: QueueEntry,
val length: Long,
@@ -36,25 +44,59 @@ data class ResolvedQueueEntry(
data class QueueSnapshot(
val entries: List<ResolvedQueueEntry>,
val upNext: List<ResolvedQueueEntry> = emptyList(),
val shuffleOrder: LongArray? = null,
val upNextAnchor: Long = 0,
val version: Long = 0,
) {
val size: Long = entries.sumOf { it.length }
val contextSize: Long = entries.sumOf { it.length }
val upNextSize: Long = upNext.sumOf { it.length }
val size: Long = contextSize + upNextSize
val shuffled: Boolean get() = shuffleOrder != null
fun locate(position: Long): Pair<QueueEntry, Long>? = locateFlat(flatPosition(position) ?: return null)
fun flatPosition(sequence: Long): Long? = if (shuffleOrder == null) sequence else shuffleOrder.getOrNull(sequence.toInt())
private fun locateFlat(position: Long): Pair<QueueEntry, Long>? {
var remaining = position
for (resolved in entries) {
if (remaining < resolved.length) return resolved.entry to remaining
remaining -= resolved.length
val anchorPlay: Long =
when (val order = shuffleOrder) {
null -> upNextAnchor
else -> order.indexOf(upNextAnchor).takeIf { it >= 0 }?.toLong() ?: 0L
}
return null
fun isUpNext(position: Long): Boolean = upNextSize > 0L && position > anchorPlay && position <= anchorPlay + upNextSize
fun upNextIndex(position: Long): Long = position - anchorPlay - 1L
fun contextPlay(position: Long): Long = if (position <= anchorPlay) position else position - upNextSize
fun anchorContextPlay(position: Long): Long = if (isUpNext(position)) anchorPlay else contextPlay(position)
fun combined(contextPlay: Long): Long = if (contextPlay <= anchorPlay) contextPlay else contextPlay + upNextSize
fun flatContext(contextPlay: Long): Long? =
when (val order = shuffleOrder) {
null -> contextPlay.takeIf { it in 0 until contextSize }
else -> order.getOrNull(contextPlay.toInt())
}
fun locate(position: Long): Pair<QueueEntry, Long>? {
if (isUpNext(position)) return locateIn(upNext, upNextIndex(position))
return locateContext(contextPlay(position))
}
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
}
private fun locateIn(
entries: List<ResolvedQueueEntry>,
index: Long,
): Pair<QueueEntry, Long>? {
var remaining = index
for (resolved in entries) {
if (remaining < resolved.length) return resolved.entry to remaining
remaining -= resolved.length
}
return null
}
data class QueueModes(
@@ -65,6 +107,7 @@ data class QueueModes(
data class QueueWindowItem(
val position: Long,
val item: SongListItem,
val upNext: Boolean = false,
)
class QueueRepository(
@@ -137,10 +180,15 @@ class QueueRepository(
suspend fun replace(
entries: List<QueueEntry>,
shuffleOrder: LongArray? = null,
upNext: List<QueueEntry> = emptyList(),
upNextAnchor: Long = 0,
) {
write(entries)
writeUpNext(upNext)
setShuffle(shuffleOrder != null, shuffleOrder)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0)) }
cursorMutex.withLock {
dao.setCursor(cursorRow().copy(queuePosition = 0, positionMs = 0, upNextAnchor = upNextAnchor))
}
}
suspend fun snapshot(): QueueSnapshot {
@@ -149,15 +197,17 @@ class QueueRepository(
db.useReaderConnection { transactor ->
transactor.deferredTransaction {
val entries = dao.entries().map { ResolvedQueueEntry(it, lengthOf(it, version)) }
val upNext =
dao.upNextEntries().map {
val entry = it.toQueueEntry()
ResolvedQueueEntry(entry, lengthOf(entry, version))
}
val row = dao.cursor()
val shuffled = row?.shuffleEnabled == true
val order = if (shuffled) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
Triple(entries, order, shuffled)
val order = if (row?.shuffleEnabled == true) loadShuffleOrder(row.shuffleSeed, entries.sumOf { it.length }) else null
LoadedSnapshot(entries, upNext, order, row?.upNextAnchor ?: 0L)
}
}
val (entries, order, shuffled) = loaded
val valid = if (shuffled && order == null) null else order
return QueueSnapshot(entries, valid, version)
return QueueSnapshot(loaded.entries, loaded.upNext, loaded.shuffleOrder, loaded.anchor, version)
}
suspend fun modes(): QueueModes {
@@ -209,29 +259,21 @@ class QueueRepository(
snapshot: QueueSnapshot,
position: Long,
): SongListItem? {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return null
val flat = snapshot.flatPosition(position) ?: return null
val id = flatIds(snapshot).getOrNull(flat.toInt()) ?: return null
return dao.songsByIds(sourceId, listOf(id)).firstOrNull()
}
val (entry, offset) = snapshot.locate(position) ?: return null
return rows(entry, entry.offset + offset, 1).firstOrNull()
}
suspend fun flatIndexOf(
suspend fun combinedIndexOf(
snapshot: QueueSnapshot,
songId: String,
): Long? {
val order = snapshot.shuffleOrder ?: return null
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
val flat = flatIds(snapshot).indexOf(songId)
if (flat < 0) return null
val position = order.indexOf(flat.toLong())
return if (position >= 0) position.toLong() else null
val play = snapshot.shuffleOrder?.indexOf(flat.toLong())?.toLong() ?: flat.toLong()
if (play < 0) return null
return snapshot.combined(play)
}
suspend fun range(
@@ -239,36 +281,20 @@ class QueueRepository(
first: Long,
last: Long,
): List<QueueWindowItem> {
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (position in first..last) {
val flat = snapshot.flatPosition(position) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += position to id
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
return requested.mapNotNull { (position, id) -> byId[id]?.let { QueueWindowItem(position, it) } }
}
val from = first.coerceAtLeast(0)
val to = last.coerceAtMost(snapshot.size - 1)
if (from > to) return emptyList()
val anchor = snapshot.anchorPlay
val block = snapshot.upNextSize
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) }
}
val beforeEnd = minOf(to, anchor)
if (from <= beforeEnd) items += contextPlayRange(snapshot, from, beforeEnd)
val blockFirst = maxOf(from, anchor + 1)
val blockLast = minOf(to, anchor + block)
if (blockFirst <= blockLast) items += blockPlayRange(snapshot, blockFirst - anchor - 1, blockLast - anchor - 1)
val afterFirst = maxOf(from, anchor + block + 1)
if (afterFirst <= to) items += contextPlayRange(snapshot, afterFirst - block, to - block)
items.sortBy { it.position }
return items
}
@@ -281,41 +307,133 @@ class QueueRepository(
return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1))
}
suspend fun addUpNext(
snapshot: QueueSnapshot,
currentPosition: Long,
entry: QueueEntry,
playNext: Boolean,
): Long {
val list = snapshot.upNext.map { it.entry }.toMutableList()
val active = snapshot.isUpNext(currentPosition)
val insertIndex =
when {
playNext && active -> 1
playNext -> 0
else -> list.size
}.coerceIn(0, list.size)
list.add(insertIndex, entry.copy(id = 0, position = 0))
val anchor =
if (snapshot.upNextSize == 0L) {
val play = snapshot.anchorContextPlay(currentPosition)
snapshot.flatContext(play) ?: 0L
} else {
snapshot.upNextAnchor
}
writeUpNext(list)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = anchor)) }
if (snapshot.upNextSize == 0L) return currentPosition
val before = snapshot.upNext.take(insertIndex).sumOf { it.length }
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
}
suspend fun removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
) {
if (index !in 0 until snapshot.upNextSize) return
writeUpNext(compact(removeEntry(snapshot.upNext, index)).map { it.entry })
}
suspend fun removeAt(
snapshot: QueueSnapshot,
position: Long,
): QueueSnapshot {
val order = snapshot.shuffleOrder
if (order == null) {
write(compact(removeEntry(snapshot.entries, position)).map { it.entry })
if (snapshot.isUpNext(position)) {
removeUpNextAt(snapshot, snapshot.upNextIndex(position))
return snapshot()
}
val flat = order.getOrNull(position.toInt()) ?: return snapshot
write(compact(removeEntry(snapshot.entries, flat)).map { it.entry })
val reordered = order.toMutableList().apply { removeAt(position.toInt()) }
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
setShuffle(true, reordered.toLongArray())
val play = snapshot.contextPlay(position)
val order = snapshot.shuffleOrder
if (order == null) {
val removed = compact(removeEntry(snapshot.entries, play))
write(removed.map { it.entry })
adjustAnchorOnRemove(play, removed.sumOf { it.length })
} else {
val flat = order.getOrNull(play.toInt()) ?: return snapshot
val removed = compact(removeEntry(snapshot.entries, flat))
write(removed.map { it.entry })
val reordered = order.toMutableList().apply { removeAt(play.toInt()) }
for (index in reordered.indices) if (reordered[index] > flat) reordered[index] -= 1
setShuffle(true, reordered.toLongArray())
adjustAnchorOnRemove(flat, removed.sumOf { it.length })
}
return snapshot()
}
private suspend fun adjustAnchorOnRemove(
removedFlat: Long,
newSize: Long,
) = cursorMutex.withLock {
val row = cursorRow()
val anchor = row.upNextAnchor
val adjusted =
when {
anchor > removedFlat -> anchor - 1
anchor == removedFlat -> removedFlat.coerceIn(0, (newSize - 1).coerceAtLeast(0))
else -> anchor
}
if (adjusted != anchor) dao.setCursor(row.copy(upNextAnchor = adjusted))
}
private suspend fun adjustAnchorOnMove(
flatFrom: Long,
flatTo: Long,
) = cursorMutex.withLock {
val row = cursorRow()
val anchor = row.upNextAnchor
val moved =
when {
flatFrom == anchor -> flatTo
flatFrom < anchor && flatTo >= anchor -> anchor - 1
flatFrom > anchor && flatTo <= anchor -> anchor + 1
else -> anchor
}
if (moved != anchor) dao.setCursor(row.copy(upNextAnchor = moved))
}
suspend fun move(
snapshot: QueueSnapshot,
from: Long,
to: Long,
): Boolean {
val fromUpNext = snapshot.isUpNext(from)
val toUpNext = snapshot.isUpNext(to)
if (fromUpNext && toUpNext) {
val song = itemAt(snapshot, from) ?: return false
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true
}
if (fromUpNext != toUpNext) return false
val playFrom = snapshot.contextPlay(from)
val playTo = snapshot.contextPlay(to)
val order = snapshot.shuffleOrder
if (order != null) {
val reordered = order.toMutableList()
val moved = reordered.removeAt(from.toInt())
reordered.add(to.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
val moved = reordered.removeAt(playFrom.toInt())
reordered.add(playTo.coerceIn(0L, reordered.size.toLong()).toInt(), moved)
setShuffle(true, reordered.toLongArray())
return true
}
val located = snapshot.locate(from) ?: return false
val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, from)
val removed = removeEntry(snapshot.entries, playFrom)
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
write(compact(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved)).map { it.entry })
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
adjustAnchorOnMove(playFrom, insertAt)
return true
}
@@ -347,27 +465,109 @@ class QueueRepository(
cachedFlatIds = null
}
private suspend fun writeUpNext(entries: List<QueueEntry>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
dao.clearUpNext()
dao.insertUpNext(
entries.mapIndexed { index, entry ->
UpNextEntry.from(entry).copy(id = 0, position = index.toLong())
},
)
}
}
queueVersion.incrementAndGet()
}
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.size) return cached
val ids = ArrayList<String>(snapshot.size.toInt())
for (resolved in snapshot.entries) {
val entry = resolved.entry
val entryIds =
when (entry.kind) {
QueueKind.Song -> listOf(entry.refId)
QueueKind.Album -> dao.albumSongIds(entry.sourceId, entry.refId)
QueueKind.Playlist -> dao.playlistSongIds(entry.sourceId, entry.refId)
}
val from = entry.offset.toInt().coerceIn(0, entryIds.size)
val to = (entry.offset + resolved.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
}
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
val ids = idsFor(snapshot.entries)
cachedFlatIds = ids
cachedFlatVersion = snapshot.version
return ids
}
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
val ids = ArrayList<String>()
for (entry in resolved) {
val ref = entry.entry
val entryIds =
when (ref.kind) {
QueueKind.Song -> listOf(ref.refId)
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
}
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
}
return ids
}
private suspend fun contextPlayRange(
snapshot: QueueSnapshot,
first: Long,
last: Long,
): List<QueueWindowItem> {
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (play in first..last) {
val flat = snapshot.flatContext(play) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += play to id
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
return items
}
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(snapshot.combined(from + index), item) }
}
return items
}
private suspend fun blockPlayRange(
snapshot: QueueSnapshot,
first: Long,
last: Long,
): List<QueueWindowItem> {
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
var start = 0L
for (resolved in snapshot.upNext) {
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(snapshot.anchorPlay + 1 + from + index, item, upNext = true)
}
}
return items
}
private suspend fun rows(
entry: QueueEntry,
offset: Long,
@@ -59,6 +59,8 @@ private sealed interface QueueUndo {
val cursor: Long
val current: Long?
val shuffleOrder: LongArray?
val upNext: List<QueueEntry>
val upNextAnchor: Long
}
private data class RemovedUndo(
@@ -68,6 +70,8 @@ private data class RemovedUndo(
val position: Long,
val item: QueueItem?,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo
private data class MovedUndo(
@@ -77,12 +81,16 @@ private data class MovedUndo(
val from: Long,
val to: Long,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo
private data class ReloadUndo(
override val entries: List<QueueEntry>,
override val cursor: Long,
override val shuffleOrder: LongArray?,
override val upNext: List<QueueEntry>,
override val upNextAnchor: Long,
) : QueueUndo {
override val current: Long? = null
}
@@ -121,6 +129,7 @@ class PlaybackController(
private var queueSourceId: Long? = null
private var windowStart = 0L
private var windowEnd = -1L
private var lastPosition: Long? = null
private var updating = false
private var lastEdit: QueueUndo? = null
private var shuffleEnabled = false
@@ -234,15 +243,15 @@ class PlaybackController(
val previous = snapshot
val previousEntry = previous?.entries?.firstOrNull()?.entry
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
val avoid = if (same) currentPosition()?.let { previous.flatPosition(it) } else null
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
queueRepository.replace(listOf(entry))
var snap = queueRepository.snapshot()
if (snap.size == 0L) {
if (snap.contextSize == 0L) {
stopLocked()
return@withLock
}
val start = randomIndex(snap.size, avoid)
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.size, start) }
val start = randomIndex(snap.contextSize, avoid)
val order = withContext(Dispatchers.Default) { shuffledOrder(snap.contextSize, start) }
queueRepository.setShuffle(true, order)
shuffleEnabled = true
queueSourceId = entry.sourceId
@@ -272,6 +281,7 @@ class PlaybackController(
queueRepository.setCursor(target)
if (target in windowStart..windowEnd) {
player.ensurePrepared()
lastPosition = target
player.seekToIndex((target - windowStart).toInt())
player.play()
shiftWindowLocked(target)
@@ -290,10 +300,11 @@ class PlaybackController(
val player = player ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (snapshot.size == 1L) {
queueRepository.removeAt(snapshot, position)
stopLocked()
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder)
lastEdit = ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
return@withLock
}
val current = currentPosition() ?: return@withLock
@@ -305,9 +316,9 @@ class PlaybackController(
windowJob?.cancel()
lastEdit =
if (removed == null) {
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else {
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder)
RemovedUndo(entries, cursor, target, position, removed, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
}
when {
position == current -> {
@@ -347,6 +358,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val cursor = queueRepository.cursor()
val entries = snapshot.entries.map { it.entry }
val upNext = snapshot.upNext.map { it.entry }
if (!queueRepository.move(snapshot, from, to)) return@withLock
this.snapshot = readSnapshot()
val target = movedCursor(current, from, to)
@@ -355,9 +367,9 @@ class PlaybackController(
val toInWindow = to in windowStart..windowEnd
lastEdit =
if (fromInWindow == toInWindow) {
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder)
MovedUndo(entries, cursor, target, from, to, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
} else {
ReloadUndo(entries, cursor, snapshot.shuffleOrder)
ReloadUndo(entries, cursor, snapshot.shuffleOrder, upNext, snapshot.upNextAnchor)
}
when {
fromInWindow && toInWindow -> {
@@ -397,7 +409,7 @@ class PlaybackController(
val player = player ?: return@withLock
val playing = player.playWhenReady
val positionMs = player.currentPositionMs
queueRepository.replace(undo.entries, undo.shuffleOrder)
queueRepository.replace(undo.entries, undo.shuffleOrder, undo.upNext, undo.upNextAnchor)
shuffleEnabled = undo.shuffleOrder != null
val restored = readSnapshot()
this.snapshot = restored
@@ -488,12 +500,33 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val current = currentPosition() ?: return
if (snapshot.isUpNext(current)) {
scope.launch { consumeUpNext(current) }
return
}
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
}
}
private suspend fun consumeUpNext(position: Long) =
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (!snapshot.isUpNext(position)) return@withLock
queueRepository.removeUpNextAt(snapshot, snapshot.upNextIndex(position))
val updated = readSnapshot()
this.snapshot = updated
windowJob?.cancel()
endedHandled = false
queueRepository.setCursor(position)
if (position < updated.size) {
loadWindow(position, autoplay = true)
} else {
refresh(position)
}
}
fun previous() {
val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
@@ -522,6 +555,45 @@ class PlaybackController(
}
}
fun addToQueue(
sourceId: Long,
kind: QueueKind,
refId: String,
) = enqueue(sourceId, kind, refId, playNext = false)
fun playNext(
sourceId: Long,
kind: QueueKind,
refId: String,
) = enqueue(sourceId, kind, refId, playNext = true)
private fun enqueue(
sourceId: Long,
kind: QueueKind,
refId: String,
playNext: Boolean,
) {
val entry = QueueEntry(position = 0, sourceId = sourceId, kind = kind, refId = refId)
scope.launch { enqueue(entry, playNext) }
}
private suspend fun enqueue(
entry: QueueEntry,
playNext: Boolean,
) = startLock.withLock {
val snapshot = snapshot ?: return@withLock
val player = player ?: return@withLock
if (snapshot.size == 0L) return@withLock
val current = currentPosition() ?: return@withLock
val position = queueRepository.addUpNext(snapshot, current, entry, playNext)
val updated = readSnapshot()
this.snapshot = updated
queueRepository.setCursor(position)
windowJob?.cancel()
rebuildWindow(queueRepository.window(updated, position, QUEUE_WINDOW_RADIUS), position)
refresh(position)
}
fun cycleRepeat() {
val next =
when (repeatMode) {
@@ -543,22 +615,27 @@ class PlaybackController(
if (snapshot.size == 0L) return@withLock
player ?: return@withLock
val current = currentPosition() ?: return@withLock
val flat = snapshot.flatPosition(current) ?: return@withLock
val wasUpNext = snapshot.isUpNext(current)
val blockIndex = snapshot.upNextIndex(current)
val flat = snapshot.flatContext(snapshot.anchorContextPlay(current)) ?: return@withLock
windowJob?.cancel()
val position =
if (shuffleEnabled) {
queueRepository.setShuffle(false, null)
shuffleEnabled = false
flat
} else {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, flat) }
queueRepository.setShuffle(true, order)
shuffleEnabled = true
0L
}
queueRepository.setCursor(position)
val enabling = !shuffleEnabled
if (enabling) {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, flat) }
queueRepository.setShuffle(true, order)
} else {
queueRepository.setShuffle(false, null)
}
shuffleEnabled = enabling
val reordered = readSnapshot()
this@PlaybackController.snapshot = reordered
val position =
when {
wasUpNext -> reordered.anchorPlay + 1 + blockIndex
enabling -> reordered.combined(0)
else -> reordered.combined(flat)
}
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(reordered, position, QUEUE_WINDOW_RADIUS), position)
endedHandled = false
refresh(position)
@@ -618,10 +695,10 @@ class PlaybackController(
stopLocked()
return@withLock
}
val start = startPosition.coerceIn(0, snapshot.size - 1)
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
val position =
if (shuffleEnabled) {
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) }
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order)
0L
} else {
@@ -643,9 +720,9 @@ class PlaybackController(
repeatMode = modes.repeat.toRepeatMode()
player?.setRepeatOne(repeatMode == RepeatMode.One)
var snapshot = queueRepository.snapshot()
if (shuffleEnabled && !snapshot.shuffled && snapshot.size > 0L) {
val start = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.size, start) }
if (shuffleEnabled && !snapshot.shuffled && snapshot.contextSize > 0L) {
val start = snapshot.anchorContextPlay(queueRepository.cursor()).coerceIn(0, snapshot.contextSize - 1)
val order = withContext(Dispatchers.Default) { shuffledOrder(snapshot.contextSize, start) }
queueRepository.setShuffle(true, order)
queueRepository.setCursor(0)
snapshot = queueRepository.snapshot()
@@ -680,6 +757,7 @@ class PlaybackController(
queueSourceId = null
windowStart = 0
windowEnd = -1
lastPosition = null
lastEdit = null
player?.run {
stop()
@@ -702,6 +780,7 @@ class PlaybackController(
if (startIndex < 0) return
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
updating = false
@@ -745,6 +824,7 @@ class PlaybackController(
player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play()
endedHandled = false
lastPosition = dest
refresh(dest)
}
}
@@ -755,6 +835,12 @@ class PlaybackController(
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
val previous = lastPosition
lastPosition = position
if (previous != null && previous != position && snapshot.isUpNext(previous)) {
consumeUpNext(previous)
return
}
lastSavedPositionMs = 0
queueRepository.setCursor(position)
refresh(position)
@@ -782,7 +868,7 @@ class PlaybackController(
if (previous != null && previous.shuffled != snapshot.shuffled) {
val currentId = player.currentItem?.id
val position =
(currentId?.let { queueRepository.flatIndexOf(snapshot, it) } ?: center)
(currentId?.let { queueRepository.combinedIndexOf(snapshot, it) } ?: center)
.coerceIn(0, snapshot.size - 1)
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS), position)
@@ -835,6 +921,7 @@ class PlaybackController(
}
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
}
private fun currentPosition(): Long? {
@@ -900,7 +987,9 @@ class PlaybackController(
}
private fun contextAt(position: Long?): QueueContext? {
val entry = position?.let { snapshot?.locate(it)?.first } ?: return null
val snapshot = snapshot ?: return null
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
val entry = snapshot.locateContext(play)?.first ?: return null
return QueueContext(entry.kind, entry.sourceId, entry.refId)
}
@@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.width
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.QueueMusic
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -41,6 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.source.StarType
import kotlinx.coroutines.launch
import com.subtracks.data.model.Album as AlbumModel
@@ -90,12 +93,28 @@ sealed interface MenuTarget {
}
}
data class QueueRef(
val sourceId: Long,
val kind: QueueKind,
val refId: String,
)
fun MenuTarget.queueRef(): QueueRef? =
when (this) {
is MenuTarget.Song -> QueueRef(song.sourceId, QueueKind.Song, song.id)
is MenuTarget.Album -> QueueRef(album.sourceId, QueueKind.Album, album.id)
is MenuTarget.Playlist -> QueueRef(playlist.sourceId, QueueKind.Playlist, playlist.id)
is MenuTarget.Artist -> null
}
class ItemActions(
val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {},
val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (PlaylistModel) -> Unit = {},
val playNext: ((QueueRef) -> Unit)? = null,
val addToQueue: ((QueueRef) -> Unit)? = null,
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null,
@@ -139,6 +158,19 @@ fun ItemContextMenu(
}
}
val queueRef = target.queueRef()
@Composable
fun queueItems() {
if (queueRef == null) return
actions.playNext?.let { next ->
MenuItem(Icons.Rounded.SkipNext, "Play next") { dismiss { next(queueRef) } }
}
actions.addToQueue?.let { add ->
MenuItem(Icons.AutoMirrored.Rounded.QueueMusic, "Add to queue") { dismiss { add(queueRef) } }
}
}
ModalBottomSheet(
onDismissRequest = { dismiss() },
sheetState = sheetState,
@@ -151,6 +183,7 @@ fun ItemContextMenu(
actions.playSong?.let { play ->
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
}
queueItems()
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
@@ -171,6 +204,7 @@ fun ItemContextMenu(
is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
queueItems()
StarItem(target.album.starred) { starring -> actions.setStar(StarType.Album, target.album.id, starring) }
target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
@@ -188,6 +222,7 @@ fun ItemContextMenu(
is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
queueItems()
}
}
Spacer(Modifier.height(16.dp))
@@ -63,6 +63,8 @@ fun AlbumDetailRoute(
playSong = { song -> songs.indexOfFirst { it.id == song.id }.takeIf { it >= 0 }?.let(viewModel::play) },
playAlbum = { viewModel.playAll() },
shuffleAlbum = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewArtist = onViewArtist,
)
@@ -214,6 +214,8 @@ fun LibraryRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -49,6 +49,8 @@ fun PlaylistDetailRoute(
ItemActions(
playPlaylist = { viewModel.playAll() },
shufflePlaylist = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -105,6 +105,8 @@ fun NowPlayingRoute(
val context = LocalPlatformContext.current
val actions =
ItemActions(
playNext = { controller.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { controller.addToQueue(it.sourceId, it.kind, it.refId) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -75,6 +76,7 @@ data class QueueRow(
val id: Long,
val position: Long,
val song: SongListItem,
val upNext: Boolean = false,
)
class QueueViewModel(
@@ -95,7 +97,7 @@ class QueueViewModel(
private var nextId = 0L
private val mutex = Mutex()
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item)
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext)
private fun reusedIds(): Map<String, MutableList<Long>> =
rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
@@ -106,7 +108,7 @@ class QueueViewModel(
): QueueRow {
val ids = reused[item.item.song.id]
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
return QueueRow(id, item.position, item.item)
return QueueRow(id, item.position, item.item, item.upNext)
}
fun open() {
@@ -169,6 +171,7 @@ class QueueViewModel(
) {
if (fromIndex == toIndex) return
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
rows.add(toIndex, rows.removeAt(fromIndex))
}
@@ -256,6 +259,12 @@ fun QueueRoute(
sourceRepository: SourceRepository = koinInject(),
) {
val playback by controller.state.collectAsStateWithLifecycle()
val queueContext = playback.context
val fallbackTitle = "Next from here"
val sourceTitle = remember(queueContext) { mutableStateOf(fallbackTitle) }
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
}
LaunchedEffect(playback.shuffle, open) { if (open) viewModel.open() }
QueueScreen(
rows = viewModel.rows,
@@ -263,6 +272,8 @@ fun QueueRoute(
initialIndex = viewModel.initialIndex,
generation = viewModel.generation,
currentSongId = playback.item?.id,
shuffle = playback.shuffle,
contextTitle = sourceTitle.value,
coverArt = sourceRepository::coverArt,
onBack = onBack,
onPlay = viewModel::play,
@@ -294,6 +305,8 @@ fun QueueScreen(
modifier: Modifier = Modifier,
initialIndex: Int = 0,
generation: Int = 0,
shuffle: Boolean = false,
contextTitle: String? = null,
) {
val listState = rememberLazyListState()
val fill = rememberViewportFill(listState)
@@ -360,6 +373,10 @@ fun QueueScreen(
) {
items(count = rows.size, key = { rows[it].id }) { index ->
val row = rows[index]
if (index == 0 || rows[index - 1].upNext != row.upNext) {
QueueSectionHeader(if (row.upNext) "Up next" else contextTitle ?: "Next up")
}
val draggable = row.upNext || !shuffle
ReorderableItem(
state = reorderState,
key = row.id,
@@ -376,26 +393,35 @@ fun QueueScreen(
floating = isDragging,
coverArt = coverArt,
dragHandle =
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from)
if (to != from) {
onMove(from, to)
showUndo("Queue reordered")
if (!draggable) {
null
} else {
Modifier.draggableHandle(
onDragStarted = {
dragId = row.id
dragFrom = row.position
},
onDragStopped = {
val id = dragId
val from = dragFrom
if (id != null && from != null) {
val index = rows.indexOfFirst { it.id == id }
val to = if (index < 0) from else dropTarget(rows, index, from)
val left = rows.getOrNull(index - 1)?.upNext
val right = rows.getOrNull(index + 1)?.upNext
val sameSection =
(left == null || left == row.upNext) &&
(right == null || right == row.upNext)
if (to != from && sameSection) {
onMove(from, to)
showUndo("Queue reordered")
}
}
}
dragId = null
dragFrom = null
},
),
dragId = null
dragFrom = null
},
)
},
onClick = { onPlay(row.position) },
onRemove = {
onRemove(row.position)
@@ -411,6 +437,16 @@ fun QueueScreen(
}
}
@Composable
private fun QueueSectionHeader(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
internal fun dropTarget(
rows: List<QueueRow>,
index: Int,
@@ -428,7 +464,7 @@ private fun QueueRowItem(
isPlaying: Boolean,
floating: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?,
dragHandle: Modifier,
dragHandle: Modifier?,
onClick: () -> Unit,
onRemove: () -> Unit,
) {
@@ -446,8 +482,10 @@ private fun QueueRowItem(
modifier = Modifier.size(20.dp),
)
}
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
if (dragHandle != null) {
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
},
@@ -372,6 +372,142 @@ class QueueRepositoryTest {
assertEquals(4, repository.snapshot().size)
}
@Test
fun upNextResolvesAfterTheCurrentTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(updated))
}
@Test
fun playNextGoesToTheFrontOfTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = true,
)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingAQueuedTrackDropsItFromTheCombinedQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.removeAt(repository.snapshot(), 1)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s3"), resolveAll(updated))
}
@Test
fun movingAQueuedTrackReordersTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "s5"),
playNext = false,
)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.move(repository.snapshot(), from = 2, to = 1)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun queuedTracksNeverJoinAShuffledContext() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.setShuffle(true, longArrayOf(2, 0, 1))
val shuffled = repository.snapshot()
repository.addUpNext(shuffled, currentPosition = 0, entry = repository.songEntry(1, "s5"), playNext = false)
val updated = repository.snapshot()
assertEquals(4, updated.size)
assertEquals("s5", repository.itemAt(updated, 1)?.song?.id)
assertEquals(listOf("s3", "s5", "s1", "s2"), resolveAll(updated))
assertTrue(updated.shuffled)
}
@Test
fun consumingASliceOfAQueuedAlbumRemovesTheRightTrack() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
assertEquals(listOf("s1", "s4", "s5", "s2", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
assertEquals(listOf("s1", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun removingTheLastAnchorTrackKeepsTheQueueResolvable() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 2, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s3", "s4"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 2)
val updated = repository.snapshot()
assertEquals(3, updated.size)
assertEquals(listOf("s1", "s2", "s4"), resolveAll(updated))
}
@Test
fun removingAContextTrackBeforeTheAnchorKeepsTheQueueAttached() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 1, entry = repository.songEntry(1, "s4"), playNext = false)
assertEquals(listOf("s1", "s2", "s4", "s3"), resolveAll(repository.snapshot()))
repository.removeAt(repository.snapshot(), 0)
assertEquals(listOf("s2", "s4", "s3"), resolveAll(repository.snapshot()))
}
@Test
fun replacingTheContextClearsTheUpNextQueue() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.songEntry(1, "s4"), playNext = false)
repository.replace(listOf(repository.songEntry(1, "s5")))
val updated = repository.snapshot()
assertTrue(updated.upNext.isEmpty())
assertEquals(1, updated.size)
assertEquals(listOf("s5"), resolveAll(updated))
}
private fun albumRange(
start: Long,
end: Long,
@@ -183,8 +183,10 @@ class FakePlayerHandle : PlayerHandle {
override fun seekToIndex(index: Int) {
operations += "seekToIndex($index)"
val changed = index != this.index
this.index = index
ended = false
if (changed) listeners.forEach { it.onTransition() }
notifyEvents()
}
@@ -192,6 +192,167 @@ class PlaybackControllerTest {
assertTrue(controller.state.value.hasPrevious)
}
@Test
fun aQueuedTrackPlaysNextAndIsConsumed() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s2"
}
assertEquals("s3", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun addingToTheQueueFromAMiddleTrackPlaysTheQueuedTrackNext() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.next()
await {
controller.state.value.item
?.id == "s3"
}
}
@Test
fun playNextGoesInFrontOfTheQueuedTracks() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
seedSong("x2", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.playNext(1, QueueKind.Song, "x2")
await { runBlocking { controller.upcomingItem()?.id } == "x2" }
controller.next()
await {
controller.state.value.item
?.id == "x2"
}
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.playAt(3)
await {
controller.state.value.item
?.id == "s3"
}
assertFalse(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun previousFromAQueuedTrackReturnsToTheContextWithoutConsuming() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
controller.previous()
await {
controller.state.value.item
?.id == "s1"
}
assertEquals("x1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun startingANewContextClearsTheQueue() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
seedAlbum(3, sourceId = 1)
@@ -1152,6 +1313,32 @@ class PlaybackControllerTest {
}
}
private fun seedSong(
id: String,
albumId: String,
) {
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = "ar1",
title = "Song $id",
album = "Other Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
}
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)